Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -348,6 +348,69 @@ def _nessun_utente(db: Path) -> bool:
|
||||
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:
|
||||
# Aspetta che i server ascoltino (max ~60 s), poi apre il browser predefinito
|
||||
for _ in range(300):
|
||||
@@ -509,6 +572,8 @@ def main(argv=None) -> int:
|
||||
|
||||
registra_strumenti() # prima del server: il worker di visione legge registry() gia' popolato (#82)
|
||||
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:
|
||||
# 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")
|
||||
|
||||
@@ -271,3 +271,77 @@ def test_avvio_normale_registra_gli_strumenti_prima_del_server(avvio, tmp_path,
|
||||
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
|
||||
|
||||
+773
-163
File diff suppressed because it is too large
Load Diff
+203
-19
@@ -29,6 +29,10 @@ Calibrazione a scacchiera (#83, logica in scripts/calibrazione.py).
|
||||
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
|
||||
@@ -57,7 +61,17 @@ Acquisizione (#87): anteprima, esposizione/guadagno, settaggi di default:
|
||||
POST /camera/parametri/salva json {source} -> {source, salvati, file}
|
||||
scrive <config-dir>/settaggi/<nome>-<sha1>.json; alla prima apertura
|
||||
della camera nel servizio il file, se c'e', viene ricaricato.
|
||||
Le calibrazioni stanno in <config-dir>/calibrazioni, le attive in attive.json (default
|
||||
#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
|
||||
@@ -83,11 +97,14 @@ camera a chiunque raggiunga la porta: solo su rete isolata.
|
||||
from __future__ import annotations
|
||||
|
||||
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 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
|
||||
@@ -110,6 +127,7 @@ from calibrazione_pagina import PAGINA # noqa: E402
|
||||
|
||||
_TRIGGER = ("continuo",)
|
||||
_MAX_CALCOLI = 8 # #91: calcoli tenuti in memoria in attesa di un nome
|
||||
_CACHE_CAMERE_S = 60.0 # #92: durata della cache della ricerca camere
|
||||
_log = logging.getLogger("camera_service") # #87: errori non bloccanti (settaggi)
|
||||
# #87: i quattro parametri regolabili, nell'ordine di presentazione
|
||||
_PARAMETRI = ("esposizione", "esposizione_auto", "guadagno", "guadagno_auto")
|
||||
@@ -154,11 +172,42 @@ def create_app(folder: Path | str | None = None,
|
||||
else _config_dir_default()) # #83
|
||||
aperte: dict = {}
|
||||
grezzi: dict = {} # #92: sorgente -> ultimo frame pieno GREZZO dell'anteprima (per anteprima/calibrata)
|
||||
bloccati: dict = {} # #94: sorgente -> copia del frame grezzo pieno «bloccata» per il calcolo (stesso lock di grezzi)
|
||||
cartella_immagini = archivio.cartella / "immagini" # #92: immagini caricate dalla pagina (creata al bisogno)
|
||||
lock = threading.Lock() # protegge SOLO i dizionari, mai un'apertura
|
||||
lock_sorgenti: dict = {} # nome sorgente -> Lock che serializza apertura e grab
|
||||
calcoli: OrderedDict = OrderedDict() # #91: calcolo_id -> (dati, mappa); gli ultimi _MAX_CALCOLI
|
||||
lock_calcoli = threading.Lock()
|
||||
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:
|
||||
if folder is None:
|
||||
@@ -169,8 +218,32 @@ def create_app(folder: Path | str | None = None,
|
||||
with lock:
|
||||
return lock_sorgenti.setdefault(nome, threading.Lock())
|
||||
|
||||
# #92: la ricerca camere di list_sources dura ~10 s: il risultato (gia' filtrato) resta 60 s in cache
|
||||
cache_camere: dict = {"voci": None, "t": 0.0}
|
||||
lock_ricerca = threading.Lock() # due richieste insieme non lanciano due ricerche
|
||||
|
||||
def _camere(aggiorna: bool = False) -> list:
|
||||
with lock_ricerca:
|
||||
fresca = cache_camere["voci"] is not None and time.monotonic() - cache_camere["t"] < _CACHE_CAMERE_S
|
||||
if fresca and not aggiorna:
|
||||
return cache_camere["voci"]
|
||||
voci = list_sources(None, cameras=True, config=_config())
|
||||
# camera:file (replay) non e' mai configurato in TMF -> fuori dall'elenco
|
||||
cache_camere["voci"] = [{"name": v.name, "kind": v.kind, "label": v.label}
|
||||
for v in voci if v.kind in ("camera", "virtual") and v.name != "camera:file"]
|
||||
cache_camere["t"] = time.monotonic()
|
||||
return cache_camere["voci"]
|
||||
|
||||
def _prima_ricerca() -> None:
|
||||
try:
|
||||
_camere()
|
||||
except Exception: # noqa: BLE001 - e' solo un riscaldamento: la prima richiesta riprova
|
||||
_log.exception("prima ricerca camere fallita")
|
||||
|
||||
@asynccontextmanager
|
||||
async def _vita(_app):
|
||||
# #92: la prima ricerca parte all'avvio, cosi' la prima pagina non aspetta
|
||||
threading.Thread(target=_prima_ricerca, daemon=True, name="ricerca-camere").start()
|
||||
yield
|
||||
with lock: # alla chiusura rilascia le camere aperte
|
||||
sorgenti = list(aperte.values())
|
||||
@@ -184,12 +257,9 @@ def create_app(folder: Path | str | None = None,
|
||||
app = FastAPI(title="TMF camera service", lifespan=_vita)
|
||||
|
||||
@app.get("/camera/sources")
|
||||
def camera_sources():
|
||||
voci = list_sources(None, cameras=True, config=_config())
|
||||
# #92: camera:file (replay) non e' mai configurato in TMF -> fuori dall'elenco
|
||||
elenco = [{"name": v.name, "kind": v.kind, "label": v.label}
|
||||
for v in voci if v.kind in ("camera", "virtual") and v.name != "camera:file"]
|
||||
# #92: in coda le immagini caricate, che sono sorgenti a pieno titolo
|
||||
def camera_sources(aggiorna: bool = False): # #92: aggiorna=true forza la ricerca (camera appena collegata)
|
||||
elenco = list(_camere(aggiorna))
|
||||
# #92: in coda le immagini caricate, sempre fresche dalla cartella (costano nulla)
|
||||
for v in list_sources(cartella_immagini, cameras=False):
|
||||
elenco.append({"name": v.name, "kind": "image", "label": f"immagine — {v.name}"})
|
||||
return {"sources": elenco}
|
||||
@@ -200,6 +270,7 @@ def create_app(folder: Path | str | None = None,
|
||||
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()
|
||||
@@ -287,7 +358,7 @@ def create_app(folder: Path | str | None = None,
|
||||
|
||||
def _grab(nome: str, timeout: int):
|
||||
"""Un frame dalla sorgente (apre e tiene aperta); errori camera -> HTTP."""
|
||||
return _con_sorgente(nome, lambda s: s.grab(timeout)) # #87: corpo in _con_sorgente
|
||||
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")
|
||||
def camera_grab(corpo: dict = Body(default=None)):
|
||||
@@ -345,6 +416,7 @@ def create_app(folder: Path | str | None = None,
|
||||
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:
|
||||
@@ -352,6 +424,13 @@ def create_app(folder: Path | str | None = None,
|
||||
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)
|
||||
@@ -374,7 +453,8 @@ def create_app(folder: Path | str | None = None,
|
||||
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"]}
|
||||
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
|
||||
|
||||
@@ -406,6 +486,35 @@ def create_app(folder: Path | str | None = None,
|
||||
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):
|
||||
@@ -476,7 +585,7 @@ def create_app(folder: Path | str | None = None,
|
||||
if isinstance(timeout, bool) or not isinstance(timeout, int) or not 1 <= timeout <= 10000:
|
||||
raise HTTPException(400, "«timeout_ms» deve essere un intero fra 1 e 10000")
|
||||
def leggi(s): # #92: il frame grezzo si tiene sotto il lock della sorgente
|
||||
f = s.grab(timeout)
|
||||
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
|
||||
@@ -508,9 +617,28 @@ def create_app(folder: Path | str | None = None,
|
||||
"X-Nitidezza": f"{nitidezza:.1f}", "X-Saturati-Pct": f"{saturati:.2f}",
|
||||
"X-Media": f"{media:.1f}"})
|
||||
|
||||
@app.post("/camera/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."""
|
||||
"""#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)
|
||||
@@ -524,14 +652,22 @@ def create_app(folder: Path | str | None = None,
|
||||
if attiva is None:
|
||||
raise HTTPException(409, "nessuna calibrazione attiva su questa sorgente")
|
||||
mappa = archivio.leggi_mappa(nome)
|
||||
if mappa is None: # planare: ha solo scala, nessuna mappa da applicare
|
||||
raise HTTPException(409, f"la calibrazione «{attiva}» non ha una mappa di correzione (planare)")
|
||||
corretto = calibrazione.raddrizza(frame, mappa)
|
||||
if corretto is None: # risoluzione diversa da quella calibrata
|
||||
ch, cw = mappa.shape[:2]
|
||||
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]
|
||||
raise HTTPException(409, f"la calibrazione «{attiva}» è per immagini {cw}×{ch}, "
|
||||
f"il frame è {larg}×{alt}")
|
||||
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)
|
||||
@@ -543,7 +679,7 @@ def create_app(folder: Path | str | None = None,
|
||||
if not ok:
|
||||
raise HTTPException(500, "codifica JPEG fallita")
|
||||
return Response(buf.tobytes(), media_type="image/jpeg", headers={
|
||||
"X-Raddrizzato": "si", "X-Larghezza": str(larg), "X-Altezza": str(alt),
|
||||
"X-Raddrizzato": raddrizzato, "X-Larghezza": str(larg), "X-Altezza": str(alt),
|
||||
"X-Calibrazione": urllib.parse.quote(attiva, safe="")})
|
||||
|
||||
@app.get("/camera/parametri")
|
||||
@@ -591,6 +727,54 @@ def create_app(folder: Path | str | None = None,
|
||||
|
||||
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():
|
||||
|
||||
@@ -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)")
|
||||
@@ -169,7 +169,7 @@ def test_c2_immagine_caricata_senza_salvare(client):
|
||||
files={"image": ("s.png", _png(una_scacchiera()), "image/png")})
|
||||
assert r.status_code == 200, r.text
|
||||
corpo = r.json()
|
||||
assert CHIAVI_PLANARE | {"overlay_png", "calcolo_id"} == set(corpo) # #91: niente "saved", niente "source"
|
||||
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)
|
||||
@@ -337,7 +337,7 @@ 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"} == set(corpo)
|
||||
assert CHIAVI_DENSA | {"overlay_png", "calcolo_id", "bloccata"} == set(corpo)
|
||||
assert corpo["metodo"] == "densa" and corpo["distorsione"] is True
|
||||
assert (corpo["cols"], corpo["rows"]) == (15, 11)
|
||||
assert corpo["px_per_mm"] == pytest.approx(1.0 / corpo["mm_per_px"])
|
||||
|
||||
@@ -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,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)
|
||||
@@ -106,6 +106,34 @@ def test_sources_con_immagini_e_senza_camera_file(client):
|
||||
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)
|
||||
@@ -254,10 +282,11 @@ def test_calibrata_risoluzione_diversa_409(client):
|
||||
assert r.json()["detail"] == "la calibrazione «Banco» è per immagini 640×480, il frame è 320×240"
|
||||
|
||||
|
||||
def test_calibrata_planare_senza_mappa_409(client):
|
||||
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 == 409
|
||||
assert r.json()["detail"] == "la calibrazione «Piana» non ha una mappa di correzione (planare)"
|
||||
assert r.status_code == 200
|
||||
assert r.headers["X-Raddrizzato"] == "no" and unquote(r.headers["X-Calibrazione"]) == "Piana"
|
||||
|
||||
@@ -29,10 +29,10 @@ def test_offline_e_senza_finestre_del_browser():
|
||||
|
||||
def test_id_principali():
|
||||
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
|
||||
attesi = {"sorgente", "avvia", "ferma", "scatta", "grezzo", "mirinoCasella", "immagine",
|
||||
attesi = {"sorgente", "avvia", "ferma", "scatta", "mirinoCasella", "immagine",
|
||||
"nitidezza", "nitidezzaMax", "saturati", "media", "dimensioni",
|
||||
"parametri", "salvaParametri", "quadrato", "acquisisci", "scegliFile", "file",
|
||||
"salva", "stato", "statoSorgente"} # #91: carica/elimina sono ora per riga, nell'elenco
|
||||
"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))
|
||||
@@ -41,8 +41,8 @@ def test_id_principali():
|
||||
|
||||
def test_sezioni_e_testi_italiani():
|
||||
for testo in ("Sorgente", "Anteprima", "Parametri camera", "Calibrazione",
|
||||
"Salva come predefiniti", "Scegli immagine...", "Questa sorgente non ha parametri regolabili",
|
||||
"Immagine grezza (senza correzione)"):
|
||||
"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
|
||||
@@ -52,3 +52,29 @@ 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
|
||||
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
|
||||
for suf in ("", "Cal", "CalTab"): # tre riquadri: grezza, calibrata, tab Calibrazione
|
||||
for base in ("zoomMeno", "zoomAdatta", "zoomPiu", "zoomPct"):
|
||||
assert base + suf in ids, base + suf
|
||||
assert "zoomata" in PAGINA
|
||||
assert "latoAnteprima()" in PAGINA and "unaAnteprima(1280)" not in PAGINA # lato_max segue lo zoom
|
||||
# la barra e' sorella del palco che scorre (non dentro): resta ferma durante lo scorrimento
|
||||
assert PAGINA.count('<div class="visore"') == 3
|
||||
assert PAGINA.count('<div class="barraZoom">') == 3
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Test della pagina a due tab (#91): contenuto statico di PAGINA, senza servizio."""
|
||||
"""Test della pagina a due tab (#91, #92): contenuto statico di PAGINA, senza servizio."""
|
||||
|
||||
import importlib.util
|
||||
import re
|
||||
@@ -20,37 +20,58 @@ def test_due_tab_con_ruolo_e_selezione():
|
||||
assert '"tmf.acq.tab"' in PAGINA # tab scelta ricordata (via memoria(), protetta)
|
||||
|
||||
|
||||
def test_anteprima_fuori_dai_pannelli():
|
||||
# l'anteprima sta nella colonna sinistra, prima dei due pannelli: visibile in entrambe le tab
|
||||
i_ant = PAGINA.index('id="sezioneAnteprima"')
|
||||
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"')
|
||||
assert i_ant < i_acq < i_cal
|
||||
# Sorgente e Parametri nel pannello Acquisizione, la calibrazione in quello Calibrazione
|
||||
assert i_acq < PAGINA.index('id="sezioneSorgente"') < PAGINA.index('id="sezioneParametri"') < i_cal
|
||||
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", "origSorgente", "origFile", "scegliFile", "file", "quadrato", "acquisisci",
|
||||
"nomeCal", "salva", "msgSalva", "calibrazioni", "msgElenco", "stato", "risultato", "overlay"}
|
||||
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 ("Sorgente selezionata", "Immagine da file", "Scegli immagine...", "Calcola",
|
||||
"Calibrazioni salvate", "si cambia nella tab Acquisizione", "risoluzione diversa",
|
||||
"Carica", "Elimina", "Scollega", "Sovrascrivi «", "Clic ancora", "Nome"):
|
||||
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/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
|
||||
@@ -65,7 +86,7 @@ def test_nessuna_finestra_del_browser_e_offline():
|
||||
|
||||
def test_acquisizione_di_87_90_conservata():
|
||||
# anteprima, indicatori, mirino, grezzo, parametri camera e ciclo a 100 ms restano
|
||||
for parola in ("Parametri camera", "Immagine grezza (senza correzione)", "Mirino centrale",
|
||||
for parola in ("Parametri camera", "Mirino centrale",
|
||||
"Math.max(100 - (Date.now() - t0), 0)", "/camera/anteprima", "/camera/parametri"):
|
||||
assert parola in PAGINA, parola
|
||||
|
||||
@@ -75,3 +96,48 @@ def test_pagina_stretta():
|
||||
# #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
|
||||
|
||||
@@ -250,6 +250,8 @@ async def create_task(
|
||||
file_type=data.file_type,
|
||||
annotations_json=data.annotations_json,
|
||||
vision_json=data.vision_json,
|
||||
# #97: camera e modo di analisi (dict gia' validato dal modello)
|
||||
acquisition_json=data.acquisition_json.model_dump() if data.acquisition_json else None,
|
||||
# #53 #55: ciclo (default dal tipo), chiusura, campi extra
|
||||
in_loop=data.in_loop if data.in_loop is not None else default_in_loop(data.task_type),
|
||||
at_close=data.at_close,
|
||||
|
||||
@@ -8,6 +8,7 @@ import base64
|
||||
import binascii
|
||||
import io
|
||||
import json
|
||||
import mimetypes
|
||||
from typing import Literal
|
||||
|
||||
from fastapi.responses import FileResponse
|
||||
@@ -23,7 +24,7 @@ from src.backend.api.routers.files import (
|
||||
from src.backend.config import settings
|
||||
from src.backend.database import get_db
|
||||
from src.backend.models.api.vision import (
|
||||
VisionExecuteResponse, VisionMeasurementResult, VisionRecipeResponse,
|
||||
VisionCheckResponse, VisionExecuteResponse, VisionMeasurementResult, VisionRecipeResponse,
|
||||
VisionRecipeUploadResponse, VisionReferenceImageResponse,
|
||||
)
|
||||
from src.backend.models.orm.vision import VisionResult
|
||||
@@ -40,14 +41,22 @@ _IMAGE_EXTENSIONS = {
|
||||
"image/png": "png",
|
||||
"image/gif": "gif",
|
||||
"image/webp": "webp",
|
||||
"image/bmp": "bmp", # #99
|
||||
"image/x-ms-bmp": "bmp", # #99
|
||||
}
|
||||
|
||||
# #99: the Opto bench cameras save BMP and the worker decodes it (PIL), so the
|
||||
# vision routes accept it on top of files.py's list. files.py is untouched: its
|
||||
# attachments are served by extension and BMP is not meant to be shown there.
|
||||
_VISION_IMAGE_TYPES = ALLOWED_IMAGE_TYPES | {"image/bmp", "image/x-ms-bmp"}
|
||||
|
||||
# Sniffed from the first bytes, independent of the declared Content-Type: a
|
||||
# text file uploaded as "image/png" passes the header check above and must
|
||||
# still be caught before it reaches the worker as a bare 500 (#15). BMP and
|
||||
# TIFF are included even though `_IMAGE_EXTENSIONS` does not accept them,
|
||||
# because PIL - and so the worker - can decode both; this check only refuses
|
||||
# what is not a real image at all, it does not narrow the allow-list above.
|
||||
# still be caught before it reaches the worker as a bare 500 (#15). TIFF is
|
||||
# included even though the allow-list does not accept it (PIL, and so the
|
||||
# worker, can decode it; browsers do not show it in the result); BMP is now
|
||||
# also allowed (#99). This check only refuses what is not a real image at all,
|
||||
# it does not narrow the allow-list above.
|
||||
_IMAGE_SIGNATURES: tuple[tuple[bytes, ...], ...] = (
|
||||
(b"\x89PNG\r\n\x1a\n",), # PNG
|
||||
(b"\xff\xd8\xff",), # JPEG
|
||||
@@ -83,7 +92,7 @@ async def _read_image(image: UploadFile) -> bytes:
|
||||
is in hand - the same two-step convention `files.py::upload_file` already
|
||||
uses, reused rather than reinvented here.
|
||||
"""
|
||||
if image.content_type not in ALLOWED_IMAGE_TYPES:
|
||||
if image.content_type not in _VISION_IMAGE_TYPES: # #99
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"File type {image.content_type} not allowed. Must be an image.",
|
||||
@@ -164,6 +173,22 @@ async def execute(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/check", response_model=VisionCheckResponse)
|
||||
async def check(
|
||||
task_id: int = Form(...),
|
||||
image: UploadFile = File(...),
|
||||
production_run_id: int | None = Form(None, gt=0),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_measurement_tec),
|
||||
) -> VisionCheckResponse:
|
||||
"""#97: analisi di un frame senza salvare nulla (ne' risultato ne' misure)."""
|
||||
task = await _task_or_404(db, task_id)
|
||||
report = await vision_service.check_task(
|
||||
db, task, await _read_image(image), production_run_id=production_run_id,
|
||||
)
|
||||
return VisionCheckResponse(**report)
|
||||
|
||||
|
||||
@router.get("/results/{result_id}/image")
|
||||
async def get_result_image(
|
||||
result_id: int,
|
||||
@@ -342,6 +367,20 @@ async def preview(
|
||||
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,
|
||||
@@ -409,8 +448,50 @@ async def create_dxf_compare_recipe(
|
||||
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')
|
||||
@@ -2,8 +2,9 @@
|
||||
import re
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from src.backend.models.api.station import _normalize_http_url
|
||||
from src.backend.models.orm.task import DEFAULT_LOOP_TASK_TYPES, TASK_TYPES
|
||||
|
||||
# Kept in step with the ORM enum so the two cannot drift apart.
|
||||
@@ -159,6 +160,30 @@ class SubtaskResponse(BaseModel):
|
||||
vision_output: Optional[str] = None
|
||||
|
||||
|
||||
class AcquisitionConfig(BaseModel):
|
||||
"""#97: camera (servizio + sorgente) e modo di analisi scelti nel task."""
|
||||
camera_url: str = Field(..., max_length=500)
|
||||
camera_source: str = Field(..., min_length=1, max_length=200)
|
||||
modo: Literal["impulsiva", "in_linea"] = "impulsiva"
|
||||
|
||||
@field_validator("camera_url")
|
||||
@classmethod
|
||||
def _check_camera_url(cls, value: str) -> str:
|
||||
# stessa normalizzazione delle stazioni; qui il vuoto non e' ammesso
|
||||
normalized = _normalize_http_url(value, "camera_url")
|
||||
if normalized is None:
|
||||
raise ValueError("camera_url must not be empty")
|
||||
return normalized
|
||||
|
||||
@field_validator("camera_source")
|
||||
@classmethod
|
||||
def _check_camera_source(cls, value: str) -> str:
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ValueError("camera_source must not be empty")
|
||||
return value
|
||||
|
||||
|
||||
class TaskCreate(BaseModel):
|
||||
"""Schema for creating a task."""
|
||||
title: str = Field(..., min_length=1, max_length=255)
|
||||
@@ -169,6 +194,7 @@ class TaskCreate(BaseModel):
|
||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||
annotations_json: Optional[dict[str, Any]] = None
|
||||
vision_json: Optional[dict] = None
|
||||
acquisition_json: Optional[AcquisitionConfig] = None # #97
|
||||
# #53: assente = default dal tipo (misure e check nel ciclo)
|
||||
in_loop: Optional[bool] = None
|
||||
at_close: bool = False
|
||||
@@ -186,6 +212,7 @@ class TaskUpdate(BaseModel):
|
||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||
annotations_json: Optional[dict[str, Any]] = None
|
||||
vision_json: Optional[dict] = None
|
||||
acquisition_json: Optional[AcquisitionConfig] = None # #97
|
||||
in_loop: Optional[bool] = None
|
||||
at_close: Optional[bool] = None
|
||||
fields: Optional[list[dict[str, Any]]] = None
|
||||
@@ -207,6 +234,7 @@ class TaskResponse(BaseModel):
|
||||
file_type: Optional[str] = None
|
||||
annotations_json: Optional[dict[str, Any]] = None
|
||||
vision_json: Optional[dict] = None
|
||||
acquisition_json: Optional[AcquisitionConfig] = None # #97
|
||||
in_loop: bool = False
|
||||
at_close: bool = False
|
||||
fields: Optional[list[dict[str, Any]]] = None
|
||||
|
||||
@@ -24,6 +24,16 @@ class VisionExecuteResponse(BaseModel):
|
||||
failures: list[dict] = []
|
||||
|
||||
|
||||
class VisionCheckResponse(BaseModel):
|
||||
"""What `POST /api/vision/check` returns (#97): the analysis, nothing saved."""
|
||||
outcome: str | None = None
|
||||
overlay: list[dict] = []
|
||||
failures: list[dict] = []
|
||||
measurements: list[VisionMeasurementResult] = []
|
||||
# esito della lettura della calibrazione: ok | no_camera | not_calibrated | unreachable
|
||||
calibration: str
|
||||
|
||||
|
||||
class VisionRecipeOutput(BaseModel):
|
||||
"""One numeric output a recipe offers to the quotes."""
|
||||
key: str
|
||||
|
||||
@@ -65,6 +65,9 @@ class RecipeTask(Base):
|
||||
# Cartella immutabile (relativa a upload_path) con recipe.json e i file di
|
||||
# meta della ricetta VisionSuite v2 (#46).
|
||||
vision_recipe_dir: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
# #97: camera e modo di analisi del task, {camera_url, camera_source, modo};
|
||||
# NULL = camera della stazione corrente, analisi impulsiva.
|
||||
acquisition_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||
# #53: il task fa parte del ciclo di produzione (default dal tipo: misure e
|
||||
# check si', gli altri no; un task 'note' si puo' mettere nel ciclo a mano).
|
||||
in_loop: Mapped[bool] = mapped_column(
|
||||
|
||||
@@ -117,6 +117,8 @@ async def _copy_tasks_to_version(
|
||||
vision_json=task.vision_json,
|
||||
# Cartella immutabile: basta il riferimento (#46).
|
||||
vision_recipe_dir=task.vision_recipe_dir,
|
||||
# #97: camera e modo di analisi seguono il task nella nuova versione.
|
||||
acquisition_json=task.acquisition_json,
|
||||
# #53 #55: ciclo, chiusura e campi extra seguono il task nella nuova versione.
|
||||
in_loop=task.in_loop,
|
||||
at_close=task.at_close,
|
||||
|
||||
@@ -224,6 +224,25 @@ async def dxf_compare_recipe(
|
||||
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
|
||||
@@ -231,35 +250,36 @@ CAL_NOT_CALIBRATED = "not_calibrated" # l'acquisitore risponde 404 (o un valore
|
||||
CAL_UNREACHABLE = "unreachable" # l'acquisitore non risponde
|
||||
|
||||
|
||||
async def fetch_station_calibration(station: Station | None) -> tuple[dict | None, str]:
|
||||
"""Legge la calibrazione dall'acquisitore della stazione (C1, #83).
|
||||
async def fetch_calibration(
|
||||
camera_url: str | None, camera_source: str | None, label: str = "",
|
||||
) -> tuple[dict | None, str]:
|
||||
"""Legge la calibrazione da un acquisitore (C1, #83; generalizzata in #97).
|
||||
|
||||
GET {camera_url}/camera/calibration?source={camera_source}. La calibrazione
|
||||
vive nell'acquisitore: TMF la legge, non la tiene. Ritorna (dict, "ok")
|
||||
oppure (None, motivo) per distinguere «non calibrata» da «irraggiungibile».
|
||||
Un px_per_mm non finito o <= 0 conta come non calibrata.
|
||||
Un px_per_mm non finito o <= 0 conta come non calibrata. `label` serve
|
||||
solo ai log (stazione o task).
|
||||
"""
|
||||
if station is None or not station.camera_url or not station.camera_source:
|
||||
if not camera_url or not camera_source:
|
||||
return None, CAL_NO_CAMERA
|
||||
url = f"{station.camera_url.rstrip('/')}/camera/calibration"
|
||||
url = f"{camera_url.rstrip('/')}/camera/calibration"
|
||||
try:
|
||||
# timeout breve: e' una lettura di servizio, non deve bloccare l'execute
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.get(url, params={"source": station.camera_source})
|
||||
response = await client.get(url, params={"source": camera_source})
|
||||
except httpx.HTTPError as exc:
|
||||
logger.warning("acquisitore della stazione %s non raggiungibile: %s", station.code, exc)
|
||||
logger.warning("acquisitore %s non raggiungibile: %s", label, exc)
|
||||
return None, CAL_UNREACHABLE
|
||||
if response.status_code == status.HTTP_404_NOT_FOUND:
|
||||
return None, CAL_NOT_CALIBRATED
|
||||
if response.status_code != 200:
|
||||
logger.warning(
|
||||
"acquisitore della stazione %s: HTTP %s", station.code, response.status_code,
|
||||
)
|
||||
logger.warning("acquisitore %s: HTTP %s", label, response.status_code)
|
||||
return None, CAL_UNREACHABLE
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError:
|
||||
logger.warning("acquisitore della stazione %s: risposta non JSON", station.code)
|
||||
logger.warning("acquisitore %s: risposta non JSON", label)
|
||||
return None, CAL_UNREACHABLE
|
||||
px_per_mm = body.get("px_per_mm") if isinstance(body, dict) else None
|
||||
if not (_is_measurable(px_per_mm) and px_per_mm > 0):
|
||||
@@ -267,6 +287,33 @@ async def fetch_station_calibration(station: Station | None) -> tuple[dict | Non
|
||||
return body, CAL_OK
|
||||
|
||||
|
||||
async def fetch_station_calibration(station: Station | None) -> tuple[dict | None, str]:
|
||||
"""Calibrazione dall'acquisitore della stazione (C1, #83): vedi fetch_calibration."""
|
||||
if station is None:
|
||||
return None, CAL_NO_CAMERA
|
||||
return await fetch_calibration(
|
||||
station.camera_url, station.camera_source, f"della stazione {station.code}",
|
||||
)
|
||||
|
||||
|
||||
def task_camera(task: RecipeTask, station: Station | None) -> tuple[str | None, str | None]:
|
||||
"""Camera di un task (#97): acquisition_json, altrimenti quella della stazione."""
|
||||
acquisition = task.acquisition_json
|
||||
if isinstance(acquisition, dict) and acquisition.get("camera_url") and acquisition.get("camera_source"):
|
||||
return acquisition["camera_url"], acquisition["camera_source"]
|
||||
if station is not None:
|
||||
return station.camera_url, station.camera_source
|
||||
return None, None
|
||||
|
||||
|
||||
async def fetch_task_calibration(
|
||||
task: RecipeTask, station: Station | None,
|
||||
) -> tuple[dict | None, str]:
|
||||
"""Calibrazione della camera del task (#97), stessi esiti CAL_* di fetch_calibration."""
|
||||
camera_url, camera_source = task_camera(task, station)
|
||||
return await fetch_calibration(camera_url, camera_source, f"del task {task.id}")
|
||||
|
||||
|
||||
async def get_station_calibration(station: Station | None) -> dict | None:
|
||||
"""La calibrazione C1 della stazione, o None in ogni altro caso (#83)."""
|
||||
calibration, _ = await fetch_station_calibration(station)
|
||||
@@ -438,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(
|
||||
task_id: int, image_bytes: bytes, overlay: list, image_ext: str = "png",
|
||||
) -> tuple[str, str]:
|
||||
@@ -498,14 +623,15 @@ async def execute_task(
|
||||
)
|
||||
# Worker della stazione della sessione di produzione (#45); senza run o senza URL: centrale
|
||||
station_url = None
|
||||
# Calibrazione letta dall'acquisitore della stazione (#83); None = non c'e'
|
||||
calibration = None
|
||||
# Calibrazione letta dall'acquisitore (#83); None = non c'e'. #97: la camera e'
|
||||
# quella del task se la ricetta ne sceglie una, altrimenti quella della stazione.
|
||||
station = None
|
||||
if run is not None:
|
||||
station = (await db.execute(
|
||||
select(Station).where(Station.id == run.station_id)
|
||||
)).scalar_one_or_none()
|
||||
station_url = (station.vision_worker_url or None) if station is not None else None
|
||||
calibration = await get_station_calibration(station)
|
||||
calibration, _ = await fetch_task_calibration(task, station)
|
||||
|
||||
if not task.vision_json:
|
||||
raise HTTPException(
|
||||
@@ -623,6 +749,85 @@ async def execute_task(
|
||||
return result, saved, report
|
||||
|
||||
|
||||
async def check_task(
|
||||
db: AsyncSession,
|
||||
task: RecipeTask,
|
||||
image_bytes: bytes,
|
||||
*,
|
||||
production_run_id: int | None = None,
|
||||
) -> dict:
|
||||
"""Esegue il grafo come execute_task ma NON salva nulla (#97).
|
||||
|
||||
Anteprima per l'operatore (ingresso nel task, ciclo «in linea»): niente
|
||||
VisionResult, niente file, niente misure. L'esito per quota usa
|
||||
calculate_pass_fail sulle quote con vision_output, nell'ordine di
|
||||
marker_number come execute. Le quote che il grafo non produce (pezzo non
|
||||
ancora in posizione) restano fuori da `measurements` e il perche' sta in
|
||||
`failures`: un'anteprima non deve dare 422 a ogni frame.
|
||||
"""
|
||||
if task.task_type not in VISION_TASK_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=f"task type {task.task_type!r} is not a camera measurement",
|
||||
)
|
||||
station = None
|
||||
station_url = None
|
||||
if production_run_id is not None:
|
||||
# stesse regole di execute: 404 / 422 / 409 sulla sessione di produzione
|
||||
run = await measurement_service.validate_run_for_version(
|
||||
db, production_run_id, task.version_id,
|
||||
)
|
||||
station = (await db.execute(
|
||||
select(Station).where(Station.id == run.station_id)
|
||||
)).scalar_one_or_none()
|
||||
station_url = (station.vision_worker_url or None) if station is not None else None
|
||||
if not task.vision_json:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="this task carries no vision graph",
|
||||
)
|
||||
quotes = (await db.execute(
|
||||
select(RecipeSubtask)
|
||||
.where(RecipeSubtask.task_id == task.id)
|
||||
.where(RecipeSubtask.vision_output.is_not(None))
|
||||
.order_by(RecipeSubtask.marker_number)
|
||||
)).scalars().all()
|
||||
if not quotes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="no quote on this task names a vision output",
|
||||
)
|
||||
|
||||
# stessa calibrazione di execute: camera del task, altrimenti della stazione
|
||||
calibration, cal_reason = await fetch_task_calibration(task, station)
|
||||
worker_kwargs: dict = {}
|
||||
if calibration is not None:
|
||||
worker_kwargs["calibration"] = {"px_per_mm": float(calibration["px_per_mm"])}
|
||||
report = await _call_worker(
|
||||
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
|
||||
worker_url=station_url,
|
||||
**worker_kwargs,
|
||||
)
|
||||
outputs = report["outputs"]
|
||||
measurements = [
|
||||
{
|
||||
"subtask_id": q.id,
|
||||
"value": float(outputs[q.vision_output]),
|
||||
"pass_fail": measurement_service.calculate_pass_fail(
|
||||
float(outputs[q.vision_output]), q,
|
||||
)[0],
|
||||
}
|
||||
for q in quotes if _is_measurable(outputs.get(q.vision_output))
|
||||
]
|
||||
return {
|
||||
"outcome": report.get("outcome"),
|
||||
"overlay": report.get("overlay") or [],
|
||||
"failures": report.get("failures") or [],
|
||||
"measurements": measurements,
|
||||
"calibration": cal_reason,
|
||||
}
|
||||
|
||||
|
||||
def _reference_file_path(reference: VisionReferenceImage) -> Path:
|
||||
return settings.upload_path / reference.path
|
||||
|
||||
@@ -753,9 +958,29 @@ async def ensure_points_quote(
|
||||
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)
|
||||
|
||||
@@ -341,3 +341,22 @@ class TestStationCameraMigration:
|
||||
assert "vision_worker_url" in cols("stations") # la 019 resta
|
||||
command.upgrade(cfg, "head") # e di nuovo su
|
||||
assert {"camera_url", "camera_source"} <= cols("stations")
|
||||
|
||||
|
||||
class TestTaskAcquisitionMigration:
|
||||
"""021_task_acquisition: recipe_tasks.acquisition_json, su e giu (#97)."""
|
||||
|
||||
def test_021_upgrade_and_downgrade(self, sqlite_database_url):
|
||||
db_path, _ = sqlite_database_url
|
||||
cfg = _alembic_config()
|
||||
|
||||
def cols(table):
|
||||
return {c["name"] for c in _inspect(db_path).get_columns(table)}
|
||||
|
||||
command.upgrade(cfg, "head")
|
||||
assert "acquisition_json" in cols("recipe_tasks")
|
||||
command.downgrade(cfg, "020_station_camera")
|
||||
assert "acquisition_json" not in cols("recipe_tasks")
|
||||
assert "camera_url" in cols("stations") # la 020 resta
|
||||
command.upgrade(cfg, "head") # e di nuovo su
|
||||
assert "acquisition_json" in cols("recipe_tasks")
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
"""#97: camera e modo di analisi nel task, POST /api/vision/check senza salvataggio.
|
||||
|
||||
Il worker e l'acquisitore non girano nei test: si fingono (fixture di
|
||||
test_vision_dxf_compare e test_vision_station_worker).
|
||||
"""
|
||||
import pytest
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from src.backend.config import settings
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||
from src.backend.models.orm.vision import VisionResult
|
||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||
from src.backend.tests.test_vision_copy_on_write import (
|
||||
_current_task_and_subtask, _seed_measurement,
|
||||
)
|
||||
from src.backend.tests.test_vision_dxf_compare import ( # noqa: F401 (fixture acquisitore)
|
||||
CAL, CAMERA_URL, acquisitore, redirect_uploads,
|
||||
)
|
||||
from src.backend.tests.test_vision_execute import an_image
|
||||
from src.backend.tests.test_vision_station_worker import ( # noqa: F401 (fixture worker_calls)
|
||||
_camera_task, _execute, _station_with_run, worker_calls,
|
||||
)
|
||||
|
||||
TASK_CAMERA = "http://10.0.0.50:8300"
|
||||
ACQ = {"camera_url": TASK_CAMERA, "camera_source": "camera:601020", "modo": "in_linea"}
|
||||
|
||||
|
||||
async def _task_with_acquisition(db_session, user_id, acquisition=ACQ):
|
||||
recipe, task = await _camera_task(db_session, user_id)
|
||||
task.acquisition_json = acquisition
|
||||
await db_session.commit()
|
||||
return recipe, task
|
||||
|
||||
|
||||
# --- modello e API del task ---------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_task_stores_and_normalizes_acquisition(client, db_session, maker_user):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||
resp = await client.post(
|
||||
f"/api/recipes/{recipe.id}/tasks",
|
||||
headers=auth_headers(maker_user),
|
||||
json={
|
||||
"title": "Camera", "task_type": "camera_measure",
|
||||
"acquisition_json": {"camera_url": " http://h:1/ ", "camera_source": " camera:1 "},
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
# url normalizzato come nelle stazioni, sorgente senza spazi, modo di default
|
||||
expected = {"camera_url": "http://h:1", "camera_source": "camera:1", "modo": "impulsiva"}
|
||||
assert body["acquisition_json"] == expected
|
||||
db_session.expunge_all()
|
||||
task = (await db_session.execute(
|
||||
select(RecipeTask).where(RecipeTask.id == body["id"])
|
||||
)).scalar_one()
|
||||
assert task.acquisition_json == expected # sul DB, non solo nella risposta
|
||||
got = await client.get(f"/api/tasks/{body['id']}", headers=auth_headers(maker_user))
|
||||
assert got.json()["acquisition_json"] == expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_task_without_acquisition_is_null(client, db_session, maker_user):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||
resp = await client.get(f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user))
|
||||
assert resp.json()[0]["acquisition_json"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("bad", [
|
||||
{"camera_url": "ftp://h", "camera_source": "camera:1"},
|
||||
{"camera_url": " ", "camera_source": "camera:1"},
|
||||
{"camera_url": "http://h", "camera_source": " "},
|
||||
{"camera_url": "http://h", "camera_source": "x" * 201},
|
||||
{"camera_url": "http://h", "camera_source": "camera:1", "modo": "continua"},
|
||||
{"camera_source": "camera:1"},
|
||||
])
|
||||
async def test_invalid_acquisition_is_422(client, db_session, maker_user, bad):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||
resp = await client.post(
|
||||
f"/api/recipes/{recipe.id}/tasks",
|
||||
headers=auth_headers(maker_user),
|
||||
json={"title": "Camera", "task_type": "camera_measure", "acquisition_json": bad},
|
||||
)
|
||||
assert resp.status_code == 422, resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_sets_and_clears_acquisition(client, db_session, maker_user):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||
_, task, _ = await _current_task_and_subtask(db_session, recipe.id)
|
||||
resp = await client.put(
|
||||
f"/api/tasks/{task.id}", headers=auth_headers(maker_user), json={"acquisition_json": ACQ},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["acquisition_json"] == ACQ
|
||||
resp = await client.put(
|
||||
f"/api/tasks/{task.id}", headers=auth_headers(maker_user), json={"acquisition_json": None},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["acquisition_json"] is None # null = camera della stazione
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_copy_on_write_preserves_acquisition(client, db_session, maker_user):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||
_, task, _ = await _current_task_and_subtask(db_session, recipe.id)
|
||||
task.acquisition_json = ACQ
|
||||
await db_session.flush()
|
||||
await _seed_measurement(db_session, recipe.id, maker_user.id)
|
||||
db_session.expunge_all()
|
||||
|
||||
resp = await client.put(
|
||||
f"/api/recipes/{recipe.id}", headers=auth_headers(maker_user),
|
||||
json={"change_notes": "forza il copy-on-write"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
db_session.expunge_all()
|
||||
version, new_task, _ = await _current_task_and_subtask(db_session, recipe.id)
|
||||
assert version.version_number == 2
|
||||
assert new_task.acquisition_json == ACQ
|
||||
|
||||
|
||||
# --- calibrazione dalla camera del task ----------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_reads_calibration_from_the_task_camera(
|
||||
client, db_session, measurement_tec_user, worker_calls, acquisitore,
|
||||
):
|
||||
recipe, task = await _task_with_acquisition(db_session, measurement_tec_user.id)
|
||||
# stazione con camera propria: la camera del task prevale
|
||||
station, run = await _station_with_run(db_session, measurement_tec_user, recipe, None)
|
||||
station.camera_url, station.camera_source = CAMERA_URL, "camera:A1"
|
||||
await db_session.commit()
|
||||
await _execute(client, measurement_tec_user, task, run)
|
||||
assert acquisitore["urls"] == [(f"{TASK_CAMERA}/camera/calibration", {"source": "camera:601020"})]
|
||||
assert worker_calls[0]["calibration"] == {"px_per_mm": CAL["px_per_mm"]}
|
||||
result = (await db_session.execute(select(VisionResult))).scalars().one()
|
||||
assert result.calibration_snapshot["px_per_mm"] == CAL["px_per_mm"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_task_camera_works_without_a_run(
|
||||
client, db_session, measurement_tec_user, worker_calls, acquisitore,
|
||||
):
|
||||
_, task = await _task_with_acquisition(db_session, measurement_tec_user.id)
|
||||
await _execute(client, measurement_tec_user, task) # niente sessione di produzione
|
||||
assert acquisitore["urls"][0][0] == f"{TASK_CAMERA}/camera/calibration"
|
||||
assert "calibration" in worker_calls[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_falls_back_to_the_station_camera(
|
||||
client, db_session, measurement_tec_user, worker_calls, acquisitore,
|
||||
):
|
||||
recipe, task = await _camera_task(db_session, measurement_tec_user.id) # acquisition_json null
|
||||
station, run = await _station_with_run(db_session, measurement_tec_user, recipe, None)
|
||||
station.camera_url, station.camera_source = CAMERA_URL, "camera:A1"
|
||||
await db_session.commit()
|
||||
await _execute(client, measurement_tec_user, task, run)
|
||||
assert acquisitore["urls"] == [(f"{CAMERA_URL}/camera/calibration", {"source": "camera:A1"})]
|
||||
assert worker_calls[0]["calibration"] == {"px_per_mm": CAL["px_per_mm"]}
|
||||
|
||||
|
||||
# --- POST /api/vision/check ----------------------------------------------------
|
||||
|
||||
async def _check(client, user, task, run=None):
|
||||
data = {"task_id": str(task.id)}
|
||||
if run is not None:
|
||||
data["production_run_id"] = str(run.id)
|
||||
return await client.post(
|
||||
"/api/vision/check", headers=auth_headers(user),
|
||||
files={"image": ("p.png", an_image(), "image/png")}, data=data,
|
||||
)
|
||||
|
||||
|
||||
async def _counts(db_session):
|
||||
return tuple([
|
||||
(await db_session.execute(select(func.count()).select_from(model))).scalar_one()
|
||||
for model in (Measurement, VisionResult)
|
||||
])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_saves_nothing_and_reports_pass_fail(
|
||||
client, db_session, measurement_tec_user, monkeypatch, acquisitore,
|
||||
):
|
||||
_, task = await _task_with_acquisition(db_session, measurement_tec_user.id)
|
||||
seen = {}
|
||||
|
||||
async def fake_call(image_bytes, graph, **kwargs):
|
||||
seen.update(kwargs)
|
||||
return {"outputs": {"t1.diameter": 11.0}, "failures": [{"tool_name": "t"}],
|
||||
"overlay": [{"kind": "line"}], "outcome": "fail",
|
||||
"engine_version": "e" * 40, "duration_ms": 3}
|
||||
|
||||
monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call)
|
||||
before = await _counts(db_session)
|
||||
|
||||
resp = await _check(client, measurement_tec_user, task)
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
quote = (await db_session.execute(
|
||||
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
||||
)).scalars().one()
|
||||
assert body == {
|
||||
"outcome": "fail", "overlay": [{"kind": "line"}], "failures": [{"tool_name": "t"}],
|
||||
"measurements": [{"subtask_id": quote.id, "value": 11.0, "pass_fail": "fail"}],
|
||||
"calibration": "ok",
|
||||
}
|
||||
assert seen["calibration"] == {"px_per_mm": CAL["px_per_mm"]} # stessa calibrazione di execute
|
||||
assert await _counts(db_session) == before # niente misure ne' VisionResult
|
||||
assert not list(settings.upload_path.rglob("*.overlay.json")) # ne' file
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("value,expected", [(10.0, "pass"), (10.4, "warning"), (9.0, "fail")])
|
||||
async def test_check_pass_fail_levels(
|
||||
client, db_session, measurement_tec_user, monkeypatch, value, expected,
|
||||
):
|
||||
_, task = await _camera_task(db_session, measurement_tec_user.id)
|
||||
|
||||
async def fake_call(image_bytes, graph, **kwargs):
|
||||
return {"outputs": {"t1.diameter": value}, "failures": [], "engine_version": "e" * 40}
|
||||
|
||||
monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call)
|
||||
resp = await _check(client, measurement_tec_user, task)
|
||||
assert resp.json()["measurements"][0]["pass_fail"] == expected
|
||||
assert resp.json()["calibration"] == "no_camera" # senza camera ne' stazione
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_calibration_reasons(
|
||||
client, db_session, measurement_tec_user, worker_calls, acquisitore,
|
||||
):
|
||||
_, task = await _task_with_acquisition(db_session, measurement_tec_user.id)
|
||||
acquisitore["cal"] = None
|
||||
assert (await _check(client, measurement_tec_user, task)).json()["calibration"] == "not_calibrated"
|
||||
acquisitore["cal"] = "down"
|
||||
assert (await _check(client, measurement_tec_user, task)).json()["calibration"] == "unreachable"
|
||||
assert "calibration" not in worker_calls[-1] # senza calibrazione la ricetta usa la sua
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_omits_quotes_the_graph_did_not_produce(
|
||||
client, db_session, measurement_tec_user, monkeypatch,
|
||||
):
|
||||
_, task = await _camera_task(db_session, measurement_tec_user.id)
|
||||
|
||||
async def fake_call(image_bytes, graph, **kwargs):
|
||||
return {"outputs": {}, "failures": [{"tool_name": "blob"}], "engine_version": "e" * 40}
|
||||
|
||||
monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call)
|
||||
resp = await _check(client, measurement_tec_user, task)
|
||||
assert resp.status_code == 200 # un'anteprima non da' 422 a ogni frame
|
||||
assert resp.json()["measurements"] == []
|
||||
assert resp.json()["failures"] == [{"tool_name": "blob"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_422_without_graph_or_quotes_or_vision_type(
|
||||
client, db_session, measurement_tec_user, worker_calls,
|
||||
):
|
||||
_, task = await _camera_task(db_session, measurement_tec_user.id)
|
||||
task.vision_json = None
|
||||
await db_session.commit()
|
||||
assert (await _check(client, measurement_tec_user, task)).status_code == 422
|
||||
|
||||
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
||||
quote = (await db_session.execute(
|
||||
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
||||
)).scalars().one()
|
||||
quote.vision_output = None
|
||||
await db_session.commit()
|
||||
assert (await _check(client, measurement_tec_user, task)).status_code == 422
|
||||
|
||||
task.task_type = "measure"
|
||||
await db_session.commit()
|
||||
assert (await _check(client, measurement_tec_user, task)).status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_with_run_uses_station_worker_and_station_camera(
|
||||
client, db_session, measurement_tec_user, worker_calls, acquisitore,
|
||||
):
|
||||
recipe, task = await _camera_task(db_session, measurement_tec_user.id)
|
||||
station, run = await _station_with_run(
|
||||
db_session, measurement_tec_user, recipe, "http://10.0.0.7:8100")
|
||||
station.camera_url, station.camera_source = CAMERA_URL, "camera:A1"
|
||||
await db_session.commit()
|
||||
before = await _counts(db_session)
|
||||
resp = await _check(client, measurement_tec_user, task, run)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert worker_calls[0]["worker_url"] == "http://10.0.0.7:8100"
|
||||
assert acquisitore["urls"][0][0] == f"{CAMERA_URL}/camera/calibration"
|
||||
assert await _counts(db_session) == before
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_requires_login(client, db_session, measurement_tec_user, worker_calls):
|
||||
_, task = await _camera_task(db_session, measurement_tec_user.id)
|
||||
resp = await client.post(
|
||||
"/api/vision/check", files={"image": ("p.png", an_image(), "image/png")},
|
||||
data={"task_id": str(task.id)},
|
||||
)
|
||||
assert resp.status_code in (401, 403)
|
||||
@@ -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,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
|
||||
@@ -182,7 +182,7 @@ def api_camera_sources():
|
||||
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
||||
return jsonify({"error": True, "detail": _("Indirizzo della camera non valido")}), 400
|
||||
try:
|
||||
resp = http_requests.get(f"{url.rstrip('/')}/camera/sources", timeout=5)
|
||||
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,
|
||||
|
||||
@@ -5,6 +5,7 @@ import math
|
||||
import requests as http_requests
|
||||
|
||||
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 _
|
||||
@@ -16,6 +17,9 @@ from services.file_proxy import proxy_file
|
||||
|
||||
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)
|
||||
@@ -541,6 +545,55 @@ def api_list_stations_for_maker():
|
||||
return jsonify(out), 200
|
||||
|
||||
|
||||
def _camera_services() -> list[dict]:
|
||||
"""#97: servizi di acquisizione distinti delle stazioni che hanno una camera_url."""
|
||||
services: dict[str, list[str]] = {}
|
||||
for st in _stations_for_maker():
|
||||
url = (st.get("camera_url") or "").strip().rstrip("/")
|
||||
if not url:
|
||||
continue
|
||||
label = f"{st.get('code') or ''} - {st.get('name') or ''}"
|
||||
services.setdefault(url, []).append(label)
|
||||
return [{"camera_url": u, "stazioni": s} for u, s in services.items()]
|
||||
|
||||
|
||||
@maker_bp.route("/api/camera/servizi", methods=["GET"])
|
||||
@login_required
|
||||
@maker_or_admin_required
|
||||
def api_camera_services():
|
||||
"""#97: servizi di acquisizione fra cui scegliere la camera di un task."""
|
||||
return jsonify({"servizi": _camera_services()}), 200
|
||||
|
||||
|
||||
@maker_bp.route("/api/camera/sorgenti", methods=["GET"])
|
||||
@login_required
|
||||
@maker_or_admin_required
|
||||
def api_camera_sources():
|
||||
"""#97: proxy di GET {url}/camera/sources; solo per i servizi delle stazioni."""
|
||||
url = (request.args.get("url") or "").strip().rstrip("/")
|
||||
# Niente richieste verso indirizzi arbitrari: url ammesso solo se e' un servizio noto
|
||||
if not url or url not in {s["camera_url"] for s in _camera_services()}:
|
||||
return jsonify({
|
||||
"error": True,
|
||||
"detail": _("Servizio di acquisizione non ammesso"),
|
||||
}), 400
|
||||
try:
|
||||
resp = http_requests.get(f"{url}/camera/sources", timeout=15)
|
||||
body = resp.json() if resp.status_code == 200 else None
|
||||
except (http_requests.RequestException, ValueError) as exc:
|
||||
return jsonify({
|
||||
"error": True,
|
||||
"detail": _("Servizio camera non raggiungibile: %(detail)s", detail=str(exc)),
|
||||
}), 502
|
||||
sources = body.get("sources") if isinstance(body, dict) else None
|
||||
if not isinstance(sources, list):
|
||||
return jsonify({
|
||||
"error": True,
|
||||
"detail": _("Risposta del servizio camera non valida (%(status)s)", status=resp.status_code),
|
||||
}), 502
|
||||
return jsonify({"sources": sources}), 200
|
||||
|
||||
|
||||
# Copia minima della logica di measure.api_vision_grab, ma per una stazione scelta per id
|
||||
@maker_bp.route("/api/stations/<int:station_id>/camera/grab", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@@ -558,7 +611,7 @@ def api_station_camera_grab(station_id: int):
|
||||
resp = http_requests.post(
|
||||
f"{camera_url.rstrip('/')}/camera/grab",
|
||||
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:
|
||||
return jsonify({
|
||||
@@ -652,6 +705,65 @@ def api_dxf_compare_recipe(task_id: int):
|
||||
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"])
|
||||
@login_required
|
||||
def api_get_file(file_path: str):
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""MeasurementTec blueprint - recipe selection and measurement execution."""
|
||||
import json
|
||||
import urllib.parse
|
||||
|
||||
import requests as http_requests
|
||||
@@ -16,6 +17,9 @@ from services.file_proxy import proxy_file
|
||||
|
||||
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.
|
||||
STATION_OVERRIDE_KEY = "station_override"
|
||||
|
||||
@@ -337,6 +341,14 @@ def task_execute(task_id: int):
|
||||
measurement_interval_minutes = recipe_resp.get("measurement_interval_minutes")
|
||||
allow_manual_input = bool(recipe_resp.get("allow_manual_input", True))
|
||||
|
||||
# #97: camera del task (acquisition_json) o della stazione; il modo di analisi
|
||||
# (impulsiva / in_linea) viene dal task. Solo i task visione ne hanno bisogno.
|
||||
camera = None
|
||||
camera_mode = "impulsiva"
|
||||
if task_resp.get("task_type") in ("camera_measure", "dxf_compare"):
|
||||
camera = _task_camera(task_resp, _current_station()[0])
|
||||
camera_mode = _task_mode(task_resp)
|
||||
|
||||
return render_template(
|
||||
"measure/task_execute.html",
|
||||
task=task_resp,
|
||||
@@ -345,7 +357,8 @@ def task_execute(task_id: int):
|
||||
all_task_ids=all_task_ids,
|
||||
measurement_interval_minutes=measurement_interval_minutes,
|
||||
allow_manual_input=allow_manual_input,
|
||||
camera_enabled=_station_camera(_current_station()[0]) is not None,
|
||||
camera_enabled=camera is not None,
|
||||
camera_mode=camera_mode,
|
||||
)
|
||||
|
||||
|
||||
@@ -1001,6 +1014,42 @@ def api_vision_execute():
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
# #97: analysis of a frame without saving anything (preview before «Registra»).
|
||||
@measure_bp.route("/api/vision/check", methods=["POST"])
|
||||
@login_required
|
||||
@role_required("MeasurementTec")
|
||||
def api_vision_check():
|
||||
"""Proxy: run the task's VisionSuite recipe on the image, nothing is saved."""
|
||||
image = request.files.get("image")
|
||||
task_id = request.form.get("task_id")
|
||||
if image is None or not task_id:
|
||||
return jsonify({
|
||||
"error": True,
|
||||
"detail": _("Dati mancanti: task_id e image sono obbligatori"),
|
||||
}), 400
|
||||
|
||||
form = {"task_id": task_id}
|
||||
if request.form.get("production_run_id"):
|
||||
form["production_run_id"] = request.form["production_run_id"]
|
||||
|
||||
resp = api_client.post(
|
||||
"/api/vision/check",
|
||||
data=form,
|
||||
files={"image": (
|
||||
image.filename or "capture.jpg",
|
||||
image.read(),
|
||||
image.mimetype or "application/octet-stream",
|
||||
)},
|
||||
)
|
||||
if resp.get("error"):
|
||||
status_code = resp.get("status_code", 500)
|
||||
body = {"error": True, "detail": resp.get("detail", _("Errore nell'esecuzione"))}
|
||||
if resp.get("session_expired"):
|
||||
body["session_expired"] = True
|
||||
return jsonify(body), status_code if status_code >= 400 else 500
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
def _station_camera(station_code: str | None) -> tuple[str, str] | None:
|
||||
"""(camera_url, camera_source) of the station, None when it has no camera.
|
||||
|
||||
@@ -1019,6 +1068,31 @@ def _station_camera(station_code: str | None) -> tuple[str, str] | None:
|
||||
return str(url), str(source)
|
||||
|
||||
|
||||
def _task_acquisition(task: dict | None) -> dict | None:
|
||||
"""#97: acquisition_json of a task as a dict, None when absent or unreadable."""
|
||||
acq = task.get("acquisition_json") if isinstance(task, dict) else None
|
||||
if isinstance(acq, str):
|
||||
try:
|
||||
acq = json.loads(acq)
|
||||
except ValueError:
|
||||
return None
|
||||
return acq if isinstance(acq, dict) else None
|
||||
|
||||
|
||||
def _task_camera(task: dict | None, station_code: str | None) -> tuple[str, str] | None:
|
||||
"""#97: camera of a task = its acquisition_json (url + source), else the station's."""
|
||||
acq = _task_acquisition(task)
|
||||
if acq and acq.get("camera_url") and acq.get("camera_source"):
|
||||
return str(acq["camera_url"]), str(acq["camera_source"])
|
||||
return _station_camera(station_code)
|
||||
|
||||
|
||||
def _task_mode(task: dict | None) -> str:
|
||||
"""#97: analysis mode of a task, «impulsiva» unless acquisition_json says «in_linea»."""
|
||||
acq = _task_acquisition(task)
|
||||
return "in_linea" if acq and acq.get("modo") == "in_linea" else "impulsiva"
|
||||
|
||||
|
||||
# #72: the browser asks the station's camera service (vs-gui) for a frame; the
|
||||
# service address is a server-side matter, so Flask relays and the PNG goes back as is.
|
||||
@measure_bp.route("/api/vision/grab", methods=["POST"])
|
||||
@@ -1026,7 +1100,16 @@ def _station_camera(station_code: str | None) -> tuple[str, str] | None:
|
||||
@role_required("MeasurementTec")
|
||||
def api_vision_grab():
|
||||
"""Proxy: grab one frame from the current station's camera, answer a PNG."""
|
||||
camera = _station_camera(_current_station()[0])
|
||||
# #97: con task_id (form o json) la camera e' quella del task, altrimenti della stazione
|
||||
payload = request.get_json(silent=True) or {}
|
||||
task_id = request.form.get("task_id") or payload.get("task_id")
|
||||
task = None
|
||||
if task_id:
|
||||
try:
|
||||
task = api_client.get(f"/api/tasks/{int(task_id)}")
|
||||
except (TypeError, ValueError):
|
||||
task = None
|
||||
camera = _task_camera(task, _current_station()[0])
|
||||
if camera is None:
|
||||
return jsonify({
|
||||
"error": True,
|
||||
@@ -1037,7 +1120,7 @@ def api_vision_grab():
|
||||
resp = http_requests.post(
|
||||
f"{camera_url.rstrip('/')}/camera/grab",
|
||||
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:
|
||||
return jsonify({
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
function dxfRecipeWizard(taskId, csrf, msgs) {
|
||||
// Colori overlay per ruolo, uguali a vision_execute.js (reference, roi, found)
|
||||
const ROLE_COLORS = { reference: '#2563eb', found: '#16a34a', roi: '#f59e0b' };
|
||||
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 {
|
||||
@@ -20,7 +21,15 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
|
||||
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)
|
||||
@@ -29,10 +38,117 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
|
||||
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
|
||||
zoom: 1, // #104: scala dell'immagine di prova (1 = pixel nativi)
|
||||
fitZoom: 1, // #104: zoom di Adatta (sotto o pari a questo il viewport segue l'immagine)
|
||||
panning: false, // #104: trascinamento in corso
|
||||
_pan: null, // #104: {x, y, left, top} al pointerdown
|
||||
resPx: null, // #104: {w, h} in pixel dell'immagine di prova (per la dimensione del wrapper)
|
||||
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)
|
||||
// #113: zoom delle due anteprime (img = immagine caricata/acquisita, dxf = disegno); w,h = misura del contenuto (px immagine; mm del viewBox per il DXF)
|
||||
views: { img: { zoom: 1, fit: 1, w: 0, h: 0 }, dxf: { zoom: 1, fit: 1, w: 0, h: 0 } },
|
||||
viewPanKey: '', // #113: anteprima in trascinamento ('' = nessuna)
|
||||
_viewPan: null, // #113: {k, x, y, left, top} al pointerdown
|
||||
_viewRO: {}, // #113: ResizeObserver per chiave, rifanno Adatta se il viewport cambia larghezza
|
||||
|
||||
// #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.stations.length) await this.loadStations();
|
||||
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() {
|
||||
@@ -49,33 +165,153 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
|
||||
|
||||
// Cambio stazione: rilegge lo stato di calibrazione dall'acquisitore
|
||||
async onStation() {
|
||||
this.calState = ''; this.calLabel = ''; this.calUrl = '';
|
||||
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 || {});
|
||||
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() {
|
||||
return !!(this.stationId && this.calibrated && this.imageFile && this.dxfFile) && !this.busy;
|
||||
// #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) {
|
||||
_setImage(file, source) {
|
||||
if (this.imageSrc) URL.revokeObjectURL(this.imageSrc);
|
||||
this.imageFile = file;
|
||||
this.imageName = file.name || '';
|
||||
this.imageSrc = URL.createObjectURL(file);
|
||||
// #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.vReset('img'); // #113: nuova immagine, vista da rifare
|
||||
this.result = null;
|
||||
this.tested = false; // #104
|
||||
this.resPx = null; // #104
|
||||
this.imageSource = file ? (source || '') : ''; // #104
|
||||
this._imgPickSeq++; // #105: una scelta piu' recente batte i file della ricetta ancora in arrivo
|
||||
},
|
||||
|
||||
// #102: l'<img> dell'anteprima e' caricata: legge i pixel; se non si carica il formato non e' mostrabile
|
||||
onImagePreviewLoad(el) {
|
||||
this.imageBroken = false;
|
||||
this.imagePx = el && el.naturalWidth ? { w: el.naturalWidth, h: el.naturalHeight } : null;
|
||||
if (this.imagePx) this.vSetContent('img', this.imagePx.w, this.imagePx.h); // #113: Adatta al caricamento
|
||||
else this.vReset('img');
|
||||
},
|
||||
onImagePreviewError() {
|
||||
this.imageBroken = !!this.imageSrc;
|
||||
this.imagePx = null;
|
||||
this.vReset('img'); // #113
|
||||
},
|
||||
// #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);
|
||||
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.vReset('dxf'); // #113: nuova anteprima in arrivo
|
||||
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;
|
||||
const vb = this.dxfPreviewViewBox().split(' ').map(Number); // #113: misura del viewBox (mm) per lo zoom
|
||||
this.vSetContent('dxf', vb[2], vb[3]);
|
||||
}
|
||||
} 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'}`;
|
||||
},
|
||||
onDxf(ev) { this.dxfFile = (ev.target.files && ev.target.files[0]) || null; this.result = null; },
|
||||
|
||||
// Frame dalla camera della stazione scelta
|
||||
async grab() {
|
||||
@@ -91,7 +327,7 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
|
||||
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' }));
|
||||
this._setImage(new File([blob], 'camera.png', { type: blob.type || 'image/png' }), 'camera'); // #104
|
||||
}
|
||||
} catch (e) {
|
||||
this.error = this.msgs.requestFailed || String(e);
|
||||
@@ -115,11 +351,17 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
|
||||
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');
|
||||
@@ -137,18 +379,201 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
|
||||
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;
|
||||
this.$nextTick(() => this.fit());
|
||||
},
|
||||
|
||||
// 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] }));
|
||||
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')})`;
|
||||
},
|
||||
|
||||
// #104: zoom dell'immagine di prova. Il wrapper e' largo naturalWidth*zoom; il canvas resta in pixel immagine e lo scala il CSS
|
||||
get zoomPct() { return Math.round(this.zoom * 100) + ' %'; },
|
||||
get zoomW() { return this.resPx ? this.resPx.w * this.zoom : 0; },
|
||||
get zoomH() { return this.resPx ? this.resPx.h * this.zoom : 0; },
|
||||
// #104: altezza massima del viewport = min(70vh, 520px)
|
||||
_maxVpH() { return Math.min(0.7 * (typeof window !== 'undefined' && window.innerHeight ? window.innerHeight : 800), 520); },
|
||||
// #104: con zoom <= Adatta il viewport e' alto come l'immagine scalata (min 200px), altrimenti al massimo
|
||||
get vpHeight() {
|
||||
const max = this._maxVpH();
|
||||
if (!this.resPx || this.zoom > this.fitZoom * 1.0001) return max;
|
||||
return Math.min(max, Math.max(200, this.zoomH));
|
||||
},
|
||||
// #104: l'immagine e' piu' grande dello spazio: si puo' trascinare
|
||||
get canPan() { return !!this.resPx && this.zoom > this.fitZoom * 1.0001; },
|
||||
get panCursor() { return this.canPan ? (this.panning ? 'grabbing' : 'grab') : 'default'; },
|
||||
// #104: pan col puntatore (mouse e touch): scroll del viewport di quanto si muove il puntatore
|
||||
onPanDown(ev) {
|
||||
const vp = this.$refs.viewport;
|
||||
if (!vp || (ev.pointerType === 'mouse' && ev.button)) return;
|
||||
// #104: senza preventDefault Chromium manda pointercancel al primo movimento del mouse e il pan si ferma
|
||||
if (ev.preventDefault) ev.preventDefault();
|
||||
this._pan = { x: ev.clientX, y: ev.clientY, left: vp.scrollLeft, top: vp.scrollTop };
|
||||
this.panning = true;
|
||||
try { vp.setPointerCapture(ev.pointerId); } catch (e) { /* puntatore gia' rilasciato */ }
|
||||
},
|
||||
onPanMove(ev) {
|
||||
const vp = this.$refs.viewport;
|
||||
if (!this._pan || !vp) return;
|
||||
vp.scrollLeft = this._pan.left - (ev.clientX - this._pan.x);
|
||||
vp.scrollTop = this._pan.top - (ev.clientY - this._pan.y);
|
||||
},
|
||||
onPanEnd() { this._pan = null; this.panning = false; },
|
||||
zoomBy(f) {
|
||||
const vp = this.$refs.viewport;
|
||||
const old = this.zoom;
|
||||
const z = Math.min(8, Math.max(0.05, old * f));
|
||||
if (z === old) return;
|
||||
// centro visibile invariato: coordinate immagine del centro prima, scroll dopo il nuovo layout
|
||||
let cx = 0, cy = 0;
|
||||
if (vp) { cx = (vp.scrollLeft + vp.clientWidth / 2) / old; cy = (vp.scrollTop + vp.clientHeight / 2) / old; }
|
||||
this.zoom = z;
|
||||
if (vp) this.$nextTick(() => { vp.scrollLeft = cx * z - vp.clientWidth / 2; vp.scrollTop = cy * z - vp.clientHeight / 2; });
|
||||
},
|
||||
// #104: Fit = tutta l'immagine nel viewport (altezza massima, non quella corrente che segue l'immagine)
|
||||
fit() {
|
||||
const vp = this.$refs.viewport, img = this.$refs.img;
|
||||
if (!vp || !img || !img.naturalWidth || !vp.clientWidth) return;
|
||||
const z = Math.min(8, Math.max(0.05, Math.min(vp.clientWidth / img.naturalWidth, this._maxVpH() / img.naturalHeight)));
|
||||
this.zoom = z;
|
||||
this.fitZoom = z;
|
||||
vp.scrollLeft = 0; vp.scrollTop = 0;
|
||||
},
|
||||
// #113: zoom per chiave ('img' | 'dxf'), stessa logica di #104 ma con stato in views[k]. Viewport fisso di 240px.
|
||||
_vpEl(k) { return this.$refs ? this.$refs[k === 'img' ? 'vpImg' : 'vpDxf'] : null; },
|
||||
_vLimits(k) {
|
||||
const v = this.views[k];
|
||||
// img: pixel nativi 5 %..800 %; dxf: da meta' a 50 volte Adatta
|
||||
return k === 'img' ? [0.05, 8] : [v.fit * 0.5, v.fit * 50];
|
||||
},
|
||||
vPct(k) {
|
||||
const v = this.views[k];
|
||||
return Math.round((k === 'img' ? v.zoom : (v.fit ? v.zoom / v.fit : 1)) * 100) + ' %';
|
||||
},
|
||||
vW(k) { const v = this.views[k]; return v.w * v.zoom; },
|
||||
vH(k) { const v = this.views[k]; return v.h * v.zoom; },
|
||||
vCanPan(k) { const v = this.views[k]; return v.w > 0 && v.zoom > v.fit * 1.0001; },
|
||||
vCursor(k) { return this.vCanPan(k) ? (this.viewPanKey === k ? 'grabbing' : 'grab') : 'default'; },
|
||||
// #113: nessun contenuto: zoom di nuovo a 1 e misura azzerata (il wrapper sparisce)
|
||||
vReset(k) { this.views[k] = { zoom: 1, fit: 1, w: 0, h: 0 }; },
|
||||
// #113: nuovo contenuto: vista da Adatta (appena il viewport ha una larghezza) e osservatore di resize
|
||||
vSetContent(k, w, h) {
|
||||
if (!(w > 0) || !(h > 0)) { this.vReset(k); return; }
|
||||
this.views[k] = { zoom: 1, fit: 1, w, h };
|
||||
const run = () => { this._vObserve(k); this.vFit(k); };
|
||||
if (typeof this.$nextTick === 'function') this.$nextTick(run); else run();
|
||||
},
|
||||
// #113: se il viewport era nascosto (larghezza 0) o cambia larghezza, rifa Adatta finche' la vista e' ad Adatta
|
||||
_vObserve(k) {
|
||||
const vp = this._vpEl(k);
|
||||
if (!vp || this._viewRO[k] || typeof ResizeObserver === 'undefined') return;
|
||||
this._viewRO[k] = new ResizeObserver(() => {
|
||||
const v = this.views[k];
|
||||
if (v.w > 0 && Math.abs(v.zoom - v.fit) < 1e-9 * Math.max(1, v.fit)) this.vFit(k);
|
||||
});
|
||||
this._viewRO[k].observe(vp);
|
||||
},
|
||||
vFit(k) {
|
||||
const vp = this._vpEl(k), v = this.views[k];
|
||||
if (!vp || !v.w || !v.h || !vp.clientWidth) return; // nascosto o vuoto: niente
|
||||
// altezza utile del viewport (senza bordo), ripiego 240; con 240 secco il contenuto sborda e compare lo scroll
|
||||
let cw = vp.clientWidth, ch = vp.clientHeight || 240;
|
||||
// #113: Adatta premuto da ingrandito: client* escludono le barre di scorrimento che spariranno dopo il fit;
|
||||
// l'area vera e' offset* meno i bordi (nei test senza DOM offsetWidth manca e restano client*)
|
||||
if (vp.offsetWidth && typeof getComputedStyle === 'function') {
|
||||
const cs = getComputedStyle(vp);
|
||||
cw = vp.offsetWidth - (parseFloat(cs.borderLeftWidth) + parseFloat(cs.borderRightWidth) || 0);
|
||||
ch = vp.offsetHeight - (parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth) || 0) || 240;
|
||||
}
|
||||
let z = Math.min(cw / v.w, ch / v.h);
|
||||
if (k === 'img') z = Math.min(8, Math.max(0.05, z));
|
||||
v.zoom = z;
|
||||
v.fit = z;
|
||||
vp.scrollLeft = 0; vp.scrollTop = 0;
|
||||
},
|
||||
vZoomBy(k, f) {
|
||||
const vp = this._vpEl(k), v = this.views[k];
|
||||
if (!v.w) return;
|
||||
const old = v.zoom, lim = this._vLimits(k);
|
||||
const z = Math.min(lim[1], Math.max(lim[0], old * f));
|
||||
if (z === old) return;
|
||||
// centro visibile invariato, come zoomBy (#104)
|
||||
let cx = 0, cy = 0;
|
||||
if (vp) { cx = (vp.scrollLeft + vp.clientWidth / 2) / old; cy = (vp.scrollTop + vp.clientHeight / 2) / old; }
|
||||
v.zoom = z;
|
||||
if (vp && typeof this.$nextTick === 'function') this.$nextTick(() => { vp.scrollLeft = cx * z - vp.clientWidth / 2; vp.scrollTop = cy * z - vp.clientHeight / 2; });
|
||||
},
|
||||
vWheel(k, ev) {
|
||||
if (!ev.ctrlKey) return;
|
||||
ev.preventDefault();
|
||||
this.vZoomBy(k, ev.deltaY < 0 ? 1.25 : 1 / 1.25);
|
||||
},
|
||||
vPanDown(k, ev) {
|
||||
const vp = this._vpEl(k);
|
||||
if (!vp || (ev.pointerType === 'mouse' && ev.button)) return;
|
||||
if (ev.preventDefault) ev.preventDefault();
|
||||
this._viewPan = { k, x: ev.clientX, y: ev.clientY, left: vp.scrollLeft, top: vp.scrollTop };
|
||||
this.viewPanKey = k;
|
||||
try { vp.setPointerCapture(ev.pointerId); } catch (e) { /* puntatore gia' rilasciato */ }
|
||||
},
|
||||
vPanMove(k, ev) {
|
||||
const vp = this._vpEl(k), p = this._viewPan;
|
||||
if (!p || p.k !== k || !vp) return;
|
||||
vp.scrollLeft = p.left - (ev.clientX - p.x);
|
||||
vp.scrollTop = p.top - (ev.clientY - p.y);
|
||||
},
|
||||
vPanEnd() { this._viewPan = null; this.viewPanKey = ''; },
|
||||
|
||||
// #104: Ctrl+rotella = zoom
|
||||
onWheel(ev) {
|
||||
if (!ev.ctrlKey) return;
|
||||
ev.preventDefault();
|
||||
this.zoomBy(ev.deltaY < 0 ? 1.25 : 1 / 1.25);
|
||||
},
|
||||
// #104: load dell'immagine di prova: dimensioni, overlay e adattamento allo spazio
|
||||
onResultImgLoad() {
|
||||
const img = this.$refs.img;
|
||||
this.resPx = img && img.naturalWidth ? { w: img.naturalWidth, h: img.naturalHeight } : null;
|
||||
this.drawOverlay();
|
||||
this.$nextTick(() => this.fit());
|
||||
},
|
||||
|
||||
fmt(v) {
|
||||
|
||||
@@ -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,70 @@
|
||||
// «Acquisizione» dell'editor task (#97): camera (servizio + sorgente) e modo di analisi
|
||||
// («impulsiva» / «in linea») dei task camera_measure e dxf_compare.
|
||||
// Legge /maker/api/camera/servizi e /maker/api/camera/sorgenti?url=. Lo stato scelto sta
|
||||
// nel componente padre (editTaskData.acquisition): questo componente carica solo gli elenchi.
|
||||
// Uso: x-data="taskAcquisition(() => editTaskData.acquisition, msgs)". Dipende da Alpine.js.
|
||||
function taskAcquisition(getAcq, msgs) {
|
||||
return {
|
||||
msgs: msgs || {},
|
||||
servizi: [],
|
||||
sources: [],
|
||||
loading: false,
|
||||
error: '',
|
||||
|
||||
async init() {
|
||||
await this.loadServizi();
|
||||
// Task con camera gia' scelta: elenco sorgenti del suo servizio
|
||||
const acq = getAcq();
|
||||
if (acq && acq.camera_url) await this.loadSources(acq.camera_url);
|
||||
},
|
||||
|
||||
async loadServizi() {
|
||||
try {
|
||||
const resp = await fetch('/maker/api/camera/servizi');
|
||||
if (resp.ok) this.servizi = (await resp.json()).servizi || [];
|
||||
} catch (e) { console.error('taskAcquisition servizi', e); }
|
||||
},
|
||||
|
||||
// Servizio salvato nel task ma non piu' fra quelli delle stazioni: resta selezionabile
|
||||
serviziConCorrente() {
|
||||
const acq = getAcq();
|
||||
const out = this.servizi.slice();
|
||||
if (acq && acq.camera_url && !out.some(s => s.camera_url === acq.camera_url)) {
|
||||
out.push({ camera_url: acq.camera_url, stazioni: [] });
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
// Cambio servizio: la sorgente scelta non vale piu'
|
||||
async onServizio() {
|
||||
const acq = getAcq();
|
||||
acq.camera_source = '';
|
||||
this.sources = [];
|
||||
this.error = '';
|
||||
if (acq.camera_url) await this.loadSources(acq.camera_url);
|
||||
},
|
||||
|
||||
async loadSources(url) {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
try {
|
||||
const resp = await fetch('/maker/api/camera/sorgenti?url=' + encodeURIComponent(url));
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (resp.ok) this.sources = data.sources || [];
|
||||
else this.error = data.detail || this.msgs.genericError || '';
|
||||
} catch (e) {
|
||||
this.error = this.msgs.genericError || String(e);
|
||||
} finally { this.loading = false; }
|
||||
},
|
||||
|
||||
// Sorgente salvata ma non piu' elencata dal servizio: resta visibile
|
||||
sourcesConCorrente() {
|
||||
const acq = getAcq();
|
||||
const out = this.sources.slice();
|
||||
if (acq && acq.camera_source && !out.some(s => s.name === acq.camera_source)) {
|
||||
out.push({ name: acq.camera_source, label: acq.camera_source });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -11,6 +11,13 @@
|
||||
* taskType, subtasks, labels.
|
||||
* Run context (lot, serial, production run) is read lazily through `ctx()`,
|
||||
* which the template binds to the parent task component.
|
||||
*
|
||||
* #97: with a camera (cameraAvailable, the task's or the station's) the task opens
|
||||
* on a frame already grabbed and analysed (/vision/check, nothing saved: «preview»).
|
||||
* modo «impulsiva»: «Acquisisci» redoes grab+check. modo «in_linea»: continuous
|
||||
* loop grab+check, one request at a time, standing still while paused / page hidden
|
||||
* / leaving / saving. «Registra» saves the LAST frame shown through /vision/execute.
|
||||
* Extra config: cameraAvailable, modo, checkUrl.
|
||||
*/
|
||||
function visionExecute() {
|
||||
// Overlay colour per role (VisionSuite roles: reference, roi, found).
|
||||
@@ -24,6 +31,125 @@ function visionExecute() {
|
||||
imageSrc: '', // object URL of the acquired image (shown under the overlay)
|
||||
ctx: () => ({}), // replaced by the template: {lot, serial, runId}
|
||||
|
||||
// #97: preview / loop state
|
||||
preview: false, // result on screen is an analysis only, not saved
|
||||
saved: false, // «Registra» done: the saved result replaces the preview
|
||||
lastBlob: null, // last frame shown, the one «Registra» saves
|
||||
paused: false, // operator paused the in-line loop
|
||||
registering: false, // «Registra» in progress: the loop stands still
|
||||
leaving: false, // page is being left: no more requests
|
||||
looping: false, // an in-line loop is running
|
||||
gen: 0, // bumped by «Registra»: answers of older requests are dropped
|
||||
|
||||
// #97: start on a frame already acquired and analysed; hook visibility and exit.
|
||||
init() {
|
||||
if (!this.hasCamera) return;
|
||||
document.addEventListener('visibilitychange', () => { if (!document.hidden) this.kick(); });
|
||||
const leave = () => { this.leaving = true; };
|
||||
window.addEventListener('pagehide', leave);
|
||||
window.addEventListener('beforeunload', leave);
|
||||
// After x-init has bound ctx(): in-line starts the loop, impulsive grabs once.
|
||||
this.$nextTick(() => { if (this.inLinea) this.loop(); else this.grab(); });
|
||||
},
|
||||
|
||||
get hasCamera() { return !!(window.__visionCfg || {}).cameraAvailable; },
|
||||
get inLinea() { return this.hasCamera && (window.__visionCfg || {}).modo === 'in_linea'; },
|
||||
// «Registra» needs a frame on screen that is not saved yet
|
||||
get canRecord() { return !!this.lastBlob && !this.saved && !this.registering; },
|
||||
|
||||
shouldRun() {
|
||||
return this.inLinea && !this.paused && !this.saved && !this.registering
|
||||
&& !this.leaving && !document.hidden;
|
||||
},
|
||||
|
||||
// Page visible again: restart the loop unless the operator paused it.
|
||||
kick() { if (this.shouldRun() && !this.looping) this.loop(); },
|
||||
|
||||
// In-line loop: one request at a time, at least 300 ms between two of them
|
||||
// (longer after an error, so a dead camera is not hammered).
|
||||
async loop() {
|
||||
if (this.looping) return;
|
||||
this.looping = true;
|
||||
try {
|
||||
while (this.shouldRun()) {
|
||||
const ok = await this.grab();
|
||||
await new Promise((r) => setTimeout(r, ok ? 300 : 2000));
|
||||
}
|
||||
} finally {
|
||||
this.looping = false;
|
||||
}
|
||||
},
|
||||
|
||||
// «Pausa» / «Riprendi»; after «Registra» only «Riprendi» restarts the loop.
|
||||
togglePause() {
|
||||
if (this.paused || this.saved) {
|
||||
this.paused = false;
|
||||
this.saved = false;
|
||||
this.loop();
|
||||
} else {
|
||||
this.paused = true;
|
||||
}
|
||||
},
|
||||
|
||||
// Analysis only (/vision/check): the frame becomes the preview, nothing is saved.
|
||||
async analyze(blob) {
|
||||
const cfg = window.__visionCfg || {};
|
||||
if (!blob || !cfg.checkUrl) return false;
|
||||
const gen = this.gen;
|
||||
this.busy = true;
|
||||
this.error = '';
|
||||
try {
|
||||
const c = this.ctx() || {};
|
||||
const fd = new FormData();
|
||||
fd.append('task_id', cfg.taskId);
|
||||
fd.append('image', blob, blob.name || 'capture.jpg');
|
||||
if (c.runId) fd.append('production_run_id', c.runId);
|
||||
const csrf = document.querySelector('meta[name=csrf-token]')?.content || '';
|
||||
const resp = await fetch(cfg.checkUrl, {
|
||||
method: 'POST', headers: { 'X-CSRFToken': csrf }, body: fd,
|
||||
});
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
if (gen !== this.gen) return false; // «Registra» took over
|
||||
if (!resp.ok || body.error) {
|
||||
this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
||||
return false;
|
||||
}
|
||||
if (this.imageSrc) URL.revokeObjectURL(this.imageSrc);
|
||||
this.imageSrc = URL.createObjectURL(blob);
|
||||
this.lastBlob = blob;
|
||||
this.result = body;
|
||||
this.preview = true;
|
||||
this.saved = false;
|
||||
this.$nextTick(() => this.drawOverlay());
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (gen === this.gen) this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
||||
return false;
|
||||
} finally {
|
||||
if (gen === this.gen) this.busy = false;
|
||||
}
|
||||
},
|
||||
|
||||
// «Registra»: save the LAST frame shown with the usual /vision/execute.
|
||||
async record() {
|
||||
if (!this.canRecord) return;
|
||||
const blob = this.lastBlob;
|
||||
this.registering = true;
|
||||
this.gen++; // drop whatever request is still in flight
|
||||
this.busy = false;
|
||||
this.grabbing = false;
|
||||
try {
|
||||
await this.acquire(blob);
|
||||
if (!this.error && this.result && this.result.vision_result_id) {
|
||||
this.preview = false;
|
||||
this.saved = true;
|
||||
}
|
||||
} finally {
|
||||
this.registering = false;
|
||||
this.kick(); // in-line: stays still while saved, restarts if the save failed
|
||||
}
|
||||
},
|
||||
|
||||
// Entry point shared with the future agent camera.
|
||||
async acquire(blob) {
|
||||
if (!blob || this.busy) return;
|
||||
@@ -64,37 +190,52 @@ function visionExecute() {
|
||||
|
||||
// Station camera (#72): the Flask proxy answers a PNG, which goes through
|
||||
// acquire() like a picked file. Errors show as the execute ones do.
|
||||
// #97: sends task_id (the task's camera wins over the station's); with a camera
|
||||
// the frame goes to the preview analysis. Returns true when the frame was analysed.
|
||||
async grab() {
|
||||
const cfg = window.__visionCfg || {};
|
||||
if (this.busy || this.grabbing || !cfg.grabUrl) return;
|
||||
if (this.busy || this.grabbing || this.registering || !cfg.grabUrl) return false;
|
||||
const gen = this.gen;
|
||||
this.grabbing = true;
|
||||
this.error = '';
|
||||
let blob = null;
|
||||
try {
|
||||
const csrf = document.querySelector('meta[name=csrf-token]')?.content || '';
|
||||
const fd = new FormData();
|
||||
fd.append('task_id', cfg.taskId);
|
||||
const resp = await fetch(cfg.grabUrl, {
|
||||
method: 'POST', headers: { 'X-CSRFToken': csrf },
|
||||
method: 'POST', headers: { 'X-CSRFToken': csrf }, body: fd,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
||||
return;
|
||||
if (gen === this.gen) this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
||||
return false;
|
||||
}
|
||||
blob = await resp.blob();
|
||||
} catch (e) {
|
||||
this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
||||
return;
|
||||
if (gen === this.gen) this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
||||
return false;
|
||||
} finally {
|
||||
this.grabbing = false;
|
||||
if (gen === this.gen) this.grabbing = false;
|
||||
}
|
||||
if (blob) await this.acquire(blob);
|
||||
if (!blob || gen !== this.gen) return false;
|
||||
if (this.hasCamera) return await this.analyze(blob);
|
||||
await this.acquire(blob);
|
||||
return !this.error;
|
||||
},
|
||||
|
||||
// Input change: take the file and allow picking the same one again.
|
||||
// #97: with a camera the file is a preview too (the loop pauses), «Registra» saves it.
|
||||
onPick(ev) {
|
||||
const f = ev.target.files && ev.target.files[0];
|
||||
ev.target.value = '';
|
||||
if (f) this.acquire(f);
|
||||
if (!f) return;
|
||||
if (this.hasCamera) {
|
||||
if (this.inLinea) this.paused = true;
|
||||
this.analyze(f);
|
||||
} else {
|
||||
this.acquire(f);
|
||||
}
|
||||
},
|
||||
|
||||
// Image shown: the saved one from the server when available, else the local one.
|
||||
@@ -113,7 +254,8 @@ function visionExecute() {
|
||||
return ((this.result && this.result.measurements) || []).map((m) => {
|
||||
const st = subs.find((s) => s.id === m.subtask_id) || {};
|
||||
return {
|
||||
label: (st.marker_number ? st.marker_number + '. ' : '') + (st.measurement_type || st.name || '#' + m.subtask_id),
|
||||
// #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,
|
||||
unit: st.unit || 'mm',
|
||||
status: m.pass_fail,
|
||||
|
||||
@@ -504,6 +504,7 @@
|
||||
|
||||
{% block extra_js %}
|
||||
<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>
|
||||
// #56: stringhe e percorsi per il ritaglio prima dell'upload
|
||||
ImageCrop.configure({
|
||||
@@ -686,6 +687,13 @@ function recipeEditor() {
|
||||
async uploadFile(file) {
|
||||
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
|
||||
const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf'];
|
||||
if (!allowed.includes(file.type)) {
|
||||
|
||||
@@ -547,6 +547,71 @@
|
||||
|
||||
<div class="tmf-card-body space-y-4">
|
||||
|
||||
<!-- ========== Acquisizione (#97): camera e modo di analisi, solo camera_measure / dxf_compare ========== -->
|
||||
<template x-if="task.task_type === 'camera_measure' || task.task_type === 'dxf_compare'">
|
||||
<div class="p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]">
|
||||
<p class="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wide mb-2">
|
||||
{{ _('Acquisizione') }}
|
||||
</p>
|
||||
<!-- Lettura: camera e modo salvati nel task -->
|
||||
<template x-if="editingTask !== task.id">
|
||||
<div class="text-xs space-y-1">
|
||||
<template x-if="task.acquisition_json && task.acquisition_json.camera_url">
|
||||
<div class="space-y-1">
|
||||
<p><span class="text-[var(--text-secondary)]">{{ _('Servizio di acquisizione') }}:</span>
|
||||
<span class="font-mono" x-text="task.acquisition_json.camera_url"></span></p>
|
||||
<p><span class="text-[var(--text-secondary)]">{{ _('Sorgente') }}:</span>
|
||||
<span class="font-mono" x-text="task.acquisition_json.camera_source"></span></p>
|
||||
<p><span class="text-[var(--text-secondary)]">{{ _('Modo di analisi') }}:</span>
|
||||
<span x-text="task.acquisition_json.modo === 'in_linea' ? {{ _('Analisi in linea')|tojson_attr }} : {{ _('Analisi impulsiva')|tojson_attr }}"></span></p>
|
||||
</div>
|
||||
</template>
|
||||
<p x-show="!(task.acquisition_json && task.acquisition_json.camera_url)" class="text-[var(--text-muted)]">
|
||||
{{ _("Camera della stazione dell'operatore") }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Modifica: servizio, sorgente, modo (salvati con «Salva» del task) -->
|
||||
<template x-if="editingTask === task.id">
|
||||
<div class="space-y-3"
|
||||
x-data="taskAcquisition(() => editTaskData.acquisition, {{ {'genericError': _('Errore durante il caricamento delle sorgenti')}|tojson_attr }})">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="tmf-label text-xs">{{ _('Servizio di acquisizione') }}</label>
|
||||
<select class="tmf-input text-xs" x-model="editTaskData.acquisition.camera_url" @change="onServizio()">
|
||||
<option value="">{{ _("Camera della stazione dell'operatore") }}</option>
|
||||
<template x-for="sv in serviziConCorrente()" :key="sv.camera_url">
|
||||
<option :value="sv.camera_url"
|
||||
x-text="sv.camera_url + (sv.stazioni.length ? ' (' + sv.stazioni.join(', ') + ')' : '')"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div x-show="editTaskData.acquisition.camera_url">
|
||||
<label class="tmf-label text-xs">{{ _('Sorgente') }}</label>
|
||||
<select class="tmf-input text-xs" x-model="editTaskData.acquisition.camera_source" :disabled="loading">
|
||||
<option value="">{{ _('Scegli la sorgente') }}</option>
|
||||
<template x-for="src in sourcesConCorrente()" :key="src.name">
|
||||
<option :value="src.name" x-text="src.label || src.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p x-show="error" class="text-xs text-red-500" x-text="error"></p>
|
||||
<div x-show="editTaskData.acquisition.camera_url" class="flex flex-wrap items-center gap-4 text-xs">
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="radio" :name="'acq-modo-' + task.id" value="impulsiva" x-model="editTaskData.acquisition.modo">
|
||||
{{ _('Analisi impulsiva') }}
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="radio" :name="'acq-modo-' + task.id" value="in_linea" x-model="editTaskData.acquisition.modo">
|
||||
{{ _('Analisi in linea') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ========== Ricetta visione (#46): solo camera_measure / dxf_compare ========== -->
|
||||
<template x-if="task.task_type === 'camera_measure' || task.task_type === 'dxf_compare'">
|
||||
<div class="p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]"
|
||||
@@ -597,11 +662,16 @@
|
||||
<!-- ========== 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')}|tojson_attr }})">
|
||||
<button type="button" class="btn btn-secondary text-xs" @click="toggle()">
|
||||
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>
|
||||
@@ -615,14 +685,14 @@
|
||||
<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" class="mt-1 text-xs text-amber-600">
|
||||
<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 -->
|
||||
<!-- 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>
|
||||
@@ -635,10 +705,84 @@
|
||||
</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 -->
|
||||
<!-- #113: barra zoom come quella della Prova (#104), per l'anteprima 'img'; x-show sul contenitore (sul viewport :style lo pesterebbe) -->
|
||||
<div class="relative" x-show="!imageBroken">
|
||||
<div x-show="!imageBroken && views.img.w" class="absolute top-2 right-2 z-10 flex items-center gap-1 rounded px-1 py-0.5"
|
||||
style="background: rgba(0,0,0,.55); color: #fff;">
|
||||
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="vZoomBy('img', 1 / 1.25)" title="{{ _('Riduci') }}">−</button>
|
||||
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="vZoomBy('img', 1.25)" title="{{ _('Ingrandisci') }}">+</button>
|
||||
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="vFit('img')">{{ _('Adatta') }}</button>
|
||||
<span class="text-xs px-1" x-text="vPct('img')"></span>
|
||||
</div>
|
||||
<!-- #113: viewport fisso 240px; il wrapper (margin auto) e' largo w*zoom: centrato se piu' piccolo, scorrevole se piu' grande -->
|
||||
<div x-ref="vpImg" @wheel="vWheel('img', $event)"
|
||||
@pointerdown="vPanDown('img', $event)" @pointermove="vPanMove('img', $event)"
|
||||
@pointerup="vPanEnd()" @pointercancel="vPanEnd()"
|
||||
class="rounded border border-[var(--border-color)]"
|
||||
:style="'overflow:auto;touch-action:none;user-select:none;display:flex;height:240px;background:var(--bg-primary);cursor:' + vCursor('img')">
|
||||
<div :style="'margin:auto;flex:none;' + (views.img.w ? ('width:' + vW('img') + 'px;height:' + vH('img') + 'px') : 'width:100%;height:240px')">{# #113: tutto in :style, Alpine con stringa sostituisce l'attributo style #}
|
||||
<img :src="imagePreviewUrl" alt="" draggable="false"
|
||||
@load="onImagePreviewLoad($event.target)" @error="onImagePreviewError()"
|
||||
class="block" style="width: 100%; height: 100%; object-fit: contain; user-select: none;">
|
||||
</div>
|
||||
</div>
|
||||
</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 #}
|
||||
<!-- #113: barra zoom come quella della Prova (#104), per l'anteprima 'dxf' -->
|
||||
<div class="relative">
|
||||
<div class="absolute top-2 right-2 z-10 flex items-center gap-1 rounded px-1 py-0.5"
|
||||
style="background: rgba(0,0,0,.55); color: #fff;">
|
||||
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="vZoomBy('dxf', 1 / 1.25)" title="{{ _('Riduci') }}">−</button>
|
||||
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="vZoomBy('dxf', 1.25)" title="{{ _('Ingrandisci') }}">+</button>
|
||||
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="vFit('dxf')">{{ _('Adatta') }}</button>
|
||||
<span class="text-xs px-1" x-text="vPct('dxf')"></span>
|
||||
</div>
|
||||
<!-- #113: stesso viewport dell'immagine; svg 100% del wrapper, viewBox in mm -->
|
||||
<div x-ref="vpDxf" @wheel="vWheel('dxf', $event)"
|
||||
@pointerdown="vPanDown('dxf', $event)" @pointermove="vPanMove('dxf', $event)"
|
||||
@pointerup="vPanEnd()" @pointercancel="vPanEnd()"
|
||||
class="rounded border border-[var(--border-color)]"
|
||||
:style="'overflow:auto;touch-action:none;user-select:none;display:flex;height:240px;background:var(--bg-primary);cursor:' + vCursor('dxf')">
|
||||
<div :style="'margin:auto;flex:none;width:' + vW('dxf') + 'px;height:' + vH('dxf') + 'px'">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-[var(--text-muted)]" x-text="dxfPreviewSummary"></p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Pochi parametri -->
|
||||
@@ -663,21 +807,57 @@
|
||||
<button type="button" class="btn btn-primary text-xs" @click="run(true)"
|
||||
:disabled="!canRun || !result">{{ _('Salva ricetta') }}</button>
|
||||
</div>
|
||||
<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>
|
||||
<!-- Anteprima con overlay e quattro valori -->
|
||||
<div x-show="imageSrc" class="relative">
|
||||
<img x-ref="img" :src="imageSrc" alt="" class="block w-full rounded border border-[var(--border-color)]"
|
||||
@load="drawOverlay()">
|
||||
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full pointer-events-none"></canvas>
|
||||
<!-- #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>
|
||||
<!-- #104: barra zoom in sovrimpressione, fuori dal contenuto scorrevole -->
|
||||
<div class="relative">
|
||||
<div class="absolute top-2 right-2 z-10 flex items-center gap-1 rounded px-1 py-0.5"
|
||||
style="background: rgba(0,0,0,.55); color: #fff;">
|
||||
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="zoomBy(1 / 1.25)" title="{{ _('Riduci') }}">−</button>
|
||||
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="zoomBy(1.25)" title="{{ _('Ingrandisci') }}">+</button>
|
||||
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="fit()">{{ _('Adatta') }}</button>
|
||||
<span class="text-xs px-1" x-text="zoomPct"></span>
|
||||
</div>
|
||||
<!-- #104: pan col puntatore (mouse e dito), cursore grab; altezza che segue l'immagine finche' zoom <= Adatta -->
|
||||
<div x-ref="viewport" @wheel="onWheel($event)"
|
||||
@pointerdown="onPanDown($event)" @pointermove="onPanMove($event)"
|
||||
@pointerup="onPanEnd()" @pointercancel="onPanEnd()"
|
||||
class="rounded border border-[var(--border-color)]"
|
||||
:style="'overflow:auto;touch-action:none;user-select:none;background:var(--bg-primary);height:' + vpHeight + 'px;cursor:' + panCursor">
|
||||
<div class="relative" :style="zoomW ? ('width:' + zoomW + 'px;height:' + zoomH + 'px') : 'width:100%'">
|
||||
<img x-ref="img" :src="imageSrc" alt="" class="block" draggable="false" style="width: 100%; height: 100%; user-select: none;"
|
||||
@load="onResultImgLoad()">
|
||||
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full pointer-events-none"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<table x-show="result" class="tmf-table text-xs">
|
||||
<tbody>
|
||||
<template x-for="v in values" :key="v.key">
|
||||
<tr><td class="font-mono" x-text="v.key"></td><td class="text-right" x-text="fmt(v.value)"></td></tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -735,6 +915,8 @@
|
||||
@change="uploadTaskFile(task.id, $event.target.files[0], task); $event.target.value = ''">
|
||||
</label>
|
||||
</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>
|
||||
|
||||
<!-- Task Directive / Description (read-only display) -->
|
||||
@@ -1335,10 +1517,12 @@
|
||||
<script src="{{ url_for('static', filename='js/vision_recipe.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/station_calibration.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/dxf_recipe_wizard.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/task_acquisition.js') }}"></script>{# #97 #}
|
||||
<script src="{{ url_for('static', filename='vendor/pdf-3.11.174.min.js') }}"></script>
|
||||
<script>if(typeof pdfjsLib!=='undefined')pdfjsLib.GlobalWorkerOptions.workerSrc='{{ url_for('static', filename='vendor/pdf.worker-3.11.174.min.js') }}';</script>
|
||||
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
|
||||
<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>
|
||||
// #56: stringhe e percorsi per il ritaglio prima dell'upload
|
||||
ImageCrop.configure({
|
||||
@@ -1385,11 +1569,12 @@ function taskEditor() {
|
||||
showAddTask: false,
|
||||
saving: false,
|
||||
errorMessage: '',
|
||||
uploadErrorTask: null, // #107: id del task il cui upload e' fallito (errore mostrato nel riquadro Disegno Tecnico)
|
||||
successMessage: '',
|
||||
|
||||
// ---- Task CRUD state ----
|
||||
editingTask: null,
|
||||
editTaskData: { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [] },
|
||||
editTaskData: { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [], acquisition: { camera_url: '', camera_source: '', modo: 'impulsiva' } }, // #97
|
||||
newTask: { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [] },
|
||||
MAX_NOTE_FIELDS: 20, // #55: limite campi per nota (stesso del backend)
|
||||
_fieldUid: 0,
|
||||
@@ -1575,6 +1760,18 @@ function taskEditor() {
|
||||
this.newTask = { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [] };
|
||||
},
|
||||
|
||||
// #97: acquisition_json del task; null = camera della stazione (voce vuota o tipo senza camera)
|
||||
_acquisitionPayload(obj) {
|
||||
const a = obj.acquisition || {};
|
||||
const usesCamera = obj.task_type === 'camera_measure' || obj.task_type === 'dxf_compare';
|
||||
if (!usesCamera || !a.camera_url) return { ok: true, value: null };
|
||||
if (!a.camera_source) {
|
||||
this.errorMessage = {{ _("Scegli la sorgente della camera")|tojson }};
|
||||
return { ok: false };
|
||||
}
|
||||
return { ok: true, value: { camera_url: a.camera_url, camera_source: a.camera_source, modo: a.modo === 'in_linea' ? 'in_linea' : 'impulsiva' } };
|
||||
},
|
||||
|
||||
// ---- #53 #55: ciclo di misura e campi della nota ----
|
||||
|
||||
// #53: default di "nel ciclo" per tipo (stessa regola del backend)
|
||||
@@ -1785,7 +1982,13 @@ function taskEditor() {
|
||||
in_loop: task.in_loop != null ? !!task.in_loop : this.loopDefault(task.task_type || 'note'),
|
||||
in_loop_touched: false,
|
||||
at_close: !!task.at_close,
|
||||
fields: this._fieldsToState(task.fields)
|
||||
fields: this._fieldsToState(task.fields),
|
||||
// #97: camera e modo di analisi del task (acquisition_json), vuoti = camera della stazione
|
||||
acquisition: {
|
||||
camera_url: (task.acquisition_json && task.acquisition_json.camera_url) || '',
|
||||
camera_source: (task.acquisition_json && task.acquisition_json.camera_source) || '',
|
||||
modo: (task.acquisition_json && task.acquisition_json.modo) || 'impulsiva'
|
||||
}
|
||||
};
|
||||
// Ensure expanded
|
||||
this.expandedTask = task.id;
|
||||
@@ -1793,7 +1996,7 @@ function taskEditor() {
|
||||
|
||||
cancelEditTask() {
|
||||
this.editingTask = null;
|
||||
this.editTaskData = { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [] };
|
||||
this.editTaskData = { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [], acquisition: { camera_url: '', camera_source: '', modo: 'impulsiva' } }; // #97
|
||||
},
|
||||
|
||||
async updateTask(taskId) {
|
||||
@@ -1801,6 +2004,8 @@ function taskEditor() {
|
||||
this.errorMessage = '';
|
||||
const fp = this._fieldsPayload(this.editTaskData); // #55
|
||||
if (!fp.ok) return;
|
||||
const acq = this._acquisitionPayload(this.editTaskData); // #97
|
||||
if (!acq.ok) return;
|
||||
this.saving = true;
|
||||
|
||||
try {
|
||||
@@ -1818,7 +2023,8 @@ function taskEditor() {
|
||||
// #53 #55: ciclo di misura, compilazione a fine produzione, campi della nota
|
||||
in_loop: !!this.editTaskData.in_loop,
|
||||
at_close: this.editTaskData.task_type === 'note' && !!this.editTaskData.at_close,
|
||||
fields: fp.fields
|
||||
fields: fp.fields,
|
||||
acquisition_json: acq.value // #97
|
||||
})
|
||||
});
|
||||
|
||||
@@ -2249,14 +2455,24 @@ function taskEditor() {
|
||||
async uploadTaskFile(taskId, file, task) {
|
||||
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'];
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
this.errorMessage = {{ _("Formato file non supportato. Usa PNG, JPG o PDF.")|tojson }};
|
||||
this.uploadErrorTask = taskId; // #107
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 20 * 1024 * 1024) {
|
||||
this.errorMessage = {{ _("File troppo grande. Dimensione massima: 20MB.")|tojson }};
|
||||
this.uploadErrorTask = taskId; // #107
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2266,6 +2482,7 @@ function taskEditor() {
|
||||
|
||||
this.saving = true;
|
||||
this.errorMessage = '';
|
||||
this.uploadErrorTask = null; // #107: nuovo tentativo, azzera l'errore del riquadro
|
||||
|
||||
try {
|
||||
// 1. Upload file
|
||||
@@ -2282,6 +2499,7 @@ function taskEditor() {
|
||||
const uploadData = await uploadResp.json();
|
||||
if (uploadData.error) {
|
||||
this.errorMessage = uploadData.detail || {{ _("Errore durante il caricamento del file")|tojson }};
|
||||
this.uploadErrorTask = taskId; // #107
|
||||
this.saving = false;
|
||||
return;
|
||||
}
|
||||
@@ -2306,6 +2524,7 @@ function taskEditor() {
|
||||
const updateData = await updateResp.json();
|
||||
if (updateData.error) {
|
||||
this.errorMessage = updateData.detail || {{ _("Errore nel salvataggio del task")|tojson }};
|
||||
this.uploadErrorTask = taskId; // #107
|
||||
this.saving = false;
|
||||
return;
|
||||
}
|
||||
@@ -2321,6 +2540,7 @@ function taskEditor() {
|
||||
} catch (err) {
|
||||
console.error('uploadTaskFile error:', err);
|
||||
this.errorMessage = {{ _("Errore di connessione al server")|tojson }};
|
||||
this.uploadErrorTask = taskId; // #107
|
||||
}
|
||||
|
||||
this.saving = false;
|
||||
|
||||
@@ -534,9 +534,10 @@
|
||||
<div x-show="isVisionTask" x-cloak
|
||||
x-data="visionExecute()"
|
||||
x-init="ctx = () => ({ lot: lotNumber, serial: serialNumber, runId: productionRun ? productionRun.id : null })"
|
||||
class="flex-1 overflow-auto p-3 flex flex-col lg:flex-row gap-3">
|
||||
class="flex-1 min-h-0 overflow-hidden p-3 flex flex-col lg:flex-row gap-3">
|
||||
{# #96: il pannello non scorre mai (overflow-hidden, min-h-0): l'immagine sta in fit nello spazio che resta #}
|
||||
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-2">
|
||||
<div class="flex-1 min-w-0 min-h-0 flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
{# On a tablet this opens the camera; on a PC it picks a file. «Riprova» reuses the same input. #}
|
||||
<label class="btn btn-primary text-sm gap-1.5 cursor-pointer" :class="busy ? 'opacity-50 pointer-events-none' : ''">
|
||||
@@ -545,12 +546,29 @@
|
||||
@change="onPick($event)" :disabled="busy">
|
||||
</label>
|
||||
{% if camera_enabled %}
|
||||
{# #72: frame dalla camera della stazione, stessa strada di acquire() #}
|
||||
{# #97: camera del task (o della stazione). Impulsiva: «Acquisisci» rifa grab+check; in linea: «Pausa»/«Riprendi» sul ciclo #}
|
||||
{% if camera_mode == 'in_linea' %}
|
||||
<button type="button" class="btn btn-secondary text-sm gap-1.5" @click="togglePause()"
|
||||
:class="registering ? 'opacity-50 pointer-events-none' : ''"
|
||||
:disabled="registering">
|
||||
<span x-text="(paused || saved) ? '{{ _('Riprendi') }}' : '{{ _('Pausa') }}'"></span>
|
||||
</button>
|
||||
{% else %}
|
||||
{# #72: frame dalla camera, stessa strada di acquire(); #97: ora grab + analisi senza salvare #}
|
||||
<button type="button" class="btn btn-secondary text-sm gap-1.5" @click="grab()"
|
||||
:class="(busy || grabbing) ? 'opacity-50 pointer-events-none' : ''"
|
||||
:disabled="busy || grabbing">
|
||||
<span>{{ _('Acquisisci dalla camera') }}</span>
|
||||
<span>{{ _('Acquisisci') }}</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
{# #97: salva l'ULTIMO frame mostrato (execute); fino ad allora e' solo un'anteprima #}
|
||||
<button type="button" class="btn btn-primary text-sm gap-1.5" @click="record()"
|
||||
:class="canRecord ? '' : 'opacity-50 pointer-events-none'"
|
||||
:disabled="!canRecord">
|
||||
<span>{{ _('Registra') }}</span>
|
||||
</button>
|
||||
<span x-show="preview && !saved" x-cloak
|
||||
class="inline-block px-2 py-0.5 rounded text-xs font-bold bg-amber-50 dark:bg-amber-900/20 border border-amber-300 dark:border-amber-700">{{ _('Anteprima, non registrata') }}</span>
|
||||
<span x-show="grabbing" class="text-sm text-[var(--text-muted)]">{{ _('Acquisizione in corso...') }}</span>
|
||||
{% endif %}
|
||||
<span x-show="busy" class="text-sm text-[var(--text-muted)]">{{ _('Elaborazione in corso...') }}</span>
|
||||
@@ -558,16 +576,18 @@
|
||||
|
||||
<div x-show="error" class="p-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-sm text-measure-fail" x-text="error"></div>
|
||||
|
||||
<div x-show="shownImage" class="relative border border-[var(--border-color)] rounded-lg overflow-hidden bg-black/5">
|
||||
<img x-ref="img" :src="shownImage" @load="drawOverlay()" alt="" class="block w-full h-auto">
|
||||
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full pointer-events-none"></canvas>
|
||||
{# #96: riquadro flex-1 min-h-0; img e canvas object-contain sovrapposti (il canvas ha la stessa risoluzione naturale dell'immagine, quindi l'overlay resta allineato) #}
|
||||
<div x-show="shownImage" class="relative flex-1 min-h-0 border border-[var(--border-color)] rounded-lg overflow-hidden bg-black/5">
|
||||
<img x-ref="img" :src="shownImage" @load="drawOverlay()" alt="" class="absolute inset-0 w-full h-full object-contain">
|
||||
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full object-contain pointer-events-none"></canvas>
|
||||
</div>
|
||||
<div x-show="!shownImage" class="text-sm text-[var(--text-muted)]">
|
||||
{{ _('Nessuna immagine acquisita: premi «Acquisisci immagine».') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lg:w-80 shrink-0 flex flex-col gap-2" x-show="result">
|
||||
{# #96: colonna esito con scorrimento proprio (solo con molte quote); sotto lg max 40% dell'altezza #}
|
||||
<div class="lg:w-80 shrink-0 min-h-0 overflow-y-auto max-h-[40%] lg:max-h-none flex flex-col gap-2" x-show="result">
|
||||
<div class="text-sm font-bold text-[var(--text-primary)]">
|
||||
{{ _('Esito') }}: <span x-text="(result && result.outcome) || '-'"></span>
|
||||
</div>
|
||||
@@ -1146,6 +1166,9 @@
|
||||
window.__visionCfg = {
|
||||
executeUrl: '{{ url_for("measure.api_vision_execute") }}',
|
||||
grabUrl: '{{ url_for("measure.api_vision_grab") }}',
|
||||
checkUrl: '{{ url_for("measure.api_vision_check") }}', {# #97: analisi senza salvare #}
|
||||
cameraAvailable: {{ (camera_enabled|default(false))|tojson }},
|
||||
modo: {{ (camera_mode|default('impulsiva'))|tojson }},
|
||||
imageUrlTpl: '{{ url_for("measure.api_vision_result_image", result_id=0) }}',
|
||||
taskId: {{ task.id|tojson }},
|
||||
taskType: {{ (task.task_type or '')|tojson }},
|
||||
|
||||
@@ -51,7 +51,9 @@
|
||||
<!-- Subtask -->
|
||||
<div>
|
||||
<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"
|
||||
@change="loadData()"
|
||||
: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">
|
||||
<option value="">{{ _("Tutti i punti") }}</option>
|
||||
@@ -287,20 +289,52 @@
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<div id="control-chart" style="min-height: 350px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<div id="histogram-chart" style="min-height: 350px;"></div>
|
||||
</div>
|
||||
@@ -346,6 +380,8 @@ function spcDashboard() {
|
||||
subtasks: [],
|
||||
|
||||
// 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,
|
||||
capability: null,
|
||||
controlChart: null,
|
||||
@@ -408,6 +444,8 @@ function spcDashboard() {
|
||||
|
||||
async onRecipeChange() {
|
||||
this.selectedSubtaskId = '';
|
||||
this.loadedSubtaskId = ''; // #100
|
||||
this.capabilityAll = []; // #100
|
||||
this.selectedRunId = ''; // #58
|
||||
this.runs = [];
|
||||
this.subtasks = [];
|
||||
@@ -459,10 +497,11 @@ function spcDashboard() {
|
||||
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.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 {
|
||||
// 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()),
|
||||
this.selectedSubtaskId
|
||||
? fetch('/statistics/api/capability?' + params.toString()).then(r => r.json())
|
||||
@@ -473,6 +512,17 @@ function spcDashboard() {
|
||||
this.selectedSubtaskId
|
||||
? fetch('/statistics/api/histogram?' + params.toString()).then(r => r.json())
|
||||
: 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)
|
||||
@@ -505,6 +555,9 @@ function spcDashboard() {
|
||||
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;
|
||||
|
||||
// Render charts after DOM update
|
||||
@@ -522,7 +575,7 @@ function spcDashboard() {
|
||||
|
||||
renderCharts() {
|
||||
// Control chart
|
||||
if (this.controlChart && this.selectedSubtaskId) {
|
||||
if (this.controlChart && this.loadedSubtaskId) { // #100
|
||||
renderControlChart('control-chart', this.controlChart);
|
||||
} else {
|
||||
const el = document.getElementById('control-chart');
|
||||
@@ -530,7 +583,7 @@ function spcDashboard() {
|
||||
}
|
||||
|
||||
// Histogram
|
||||
if (this.histogram && this.selectedSubtaskId) {
|
||||
if (this.histogram && this.loadedSubtaskId) { // #100
|
||||
const tol = this.capability ? {
|
||||
utl: this.capability.utl,
|
||||
ltl: this.capability.ltl,
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* #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({ naturalWidth: 2000, naturalHeight: 1000 });
|
||||
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);
|
||||
|
||||
// #104: zoomBy rispetta i limiti 0.05..8
|
||||
const w5 = dxfRecipeWizard(1, () => 'tok', {});
|
||||
w5.$nextTick = f => f();
|
||||
w5.$refs = {};
|
||||
for (let i = 0; i < 40; i++) w5.zoomBy(1.25);
|
||||
check('#104 zoom massimo 8', w5.zoom === 8, String(w5.zoom));
|
||||
for (let i = 0; i < 80; i++) w5.zoomBy(1 / 1.25);
|
||||
check('#104 zoom minimo 0.05', w5.zoom === 0.05, String(w5.zoom));
|
||||
w5.zoom = 1;
|
||||
const vp = { scrollLeft: 100, scrollTop: 50, clientWidth: 200, clientHeight: 100 };
|
||||
w5.$refs = { viewport: vp };
|
||||
w5.zoomBy(2);
|
||||
check('#104 zoomBy tiene il centro', w5.zoom === 2 && vp.scrollLeft === 300 && vp.scrollTop === 150, vp.scrollLeft + ',' + vp.scrollTop);
|
||||
check('#104 zoomPct', w5.zoomPct === '200 %');
|
||||
|
||||
// #104: fit = min(viewport/immagine)
|
||||
w5.$refs = { viewport: { clientWidth: 500, clientHeight: 400, scrollLeft: 9, scrollTop: 9 }, img: { naturalWidth: 2000, naturalHeight: 1000 } };
|
||||
w5.fit();
|
||||
check('#104 fit limitato dalla larghezza', Math.abs(w5.zoom - 0.25) < 1e-9, String(w5.zoom));
|
||||
w5.$refs.img = { naturalWidth: 1000, naturalHeight: 2000 };
|
||||
w5.fit();
|
||||
check('#104 fit limitato dall altezza massima (520 senza window)', Math.abs(w5.zoom - 0.26) < 1e-9, String(w5.zoom));
|
||||
w5.$refs.viewport.clientWidth = 0; w5.zoom = 1; w5.fit();
|
||||
check('#104 fit con viewport nascosto non cambia', w5.zoom === 1);
|
||||
|
||||
// #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);
|
||||
|
||||
// #104: pan col puntatore
|
||||
const wpan = dxfRecipeWizard(1, () => 'tok', {});
|
||||
const vppan = { scrollLeft: 100, scrollTop: 100, clientWidth: 500, clientHeight: 400, setPointerCapture() {} };
|
||||
wpan.$refs = { viewport: vppan };
|
||||
wpan.onPanDown({ clientX: 200, clientY: 200, pointerId: 1, pointerType: 'mouse', button: 0 });
|
||||
wpan.onPanMove({ clientX: 170, clientY: 180 });
|
||||
check('#104 pan: pointermove (-30,-20) -> scroll +30/+20', vppan.scrollLeft === 130 && vppan.scrollTop === 120, vppan.scrollLeft + ',' + vppan.scrollTop);
|
||||
check('#104 pan: grabbing durante il trascinamento', wpan.panning === true);
|
||||
wpan.onPanEnd();
|
||||
wpan.onPanMove({ clientX: 0, clientY: 0 });
|
||||
check('#104 pan: dopo pointerup non scrolla', vppan.scrollLeft === 130 && wpan.panning === false);
|
||||
wpan.onPanDown({ clientX: 0, clientY: 0, pointerId: 1, pointerType: 'mouse', button: 2 });
|
||||
check('#104 pan: il tasto destro non parte', wpan.panning === false);
|
||||
|
||||
// #104: altezza del viewport
|
||||
global.window = { innerHeight: 1000 }; // max = min(700, 520) = 520
|
||||
wpan.resPx = { w: 2000, h: 1000 };
|
||||
wpan.$refs = { viewport: { clientWidth: 500, scrollLeft: 0, scrollTop: 0 }, img: { naturalWidth: 2000, naturalHeight: 1000 } };
|
||||
wpan.fit(); // zoom = min(500/2000, 520/1000) = 0.25
|
||||
check('#104 fit usa l altezza massima', Math.abs(wpan.zoom - 0.25) < 1e-9 && wpan.fitZoom === wpan.zoom, String(wpan.zoom));
|
||||
check('#104 altezza a Fit = immagine scalata', wpan.vpHeight === 250 && wpan.canPan === false, String(wpan.vpHeight)); // zoomH = 250
|
||||
wpan.resPx = { w: 2000, h: 400 }; // zoomH = 100 -> minimo 200
|
||||
check('#104 altezza minima 200', wpan.vpHeight === 200, String(wpan.vpHeight));
|
||||
wpan.zoom = 1;
|
||||
check('#104 con zoom > Adatta altezza massima e pan attivo', wpan.vpHeight === 520 && wpan.canPan === true && wpan.panCursor === 'grab');
|
||||
global.window = { innerHeight: 500 }; // 70vh = 350
|
||||
check('#104 altezza massima = 70vh se minore di 520', wpan.vpHeight === 350, String(wpan.vpHeight));
|
||||
delete global.window;
|
||||
|
||||
if (failures) { console.log(failures + ' fallimenti'); process.exit(1); }
|
||||
console.log('tutto ok');
|
||||
})();
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* #113: zoom, Adatta e pan sulle anteprime del wizard (immagine e DXF), con plain node.
|
||||
*
|
||||
* 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 near = (a, b) => Math.abs(a - b) < 1e-9 * Math.max(1, Math.abs(b));
|
||||
|
||||
// Wizard con viewport finti e $nextTick sincrono
|
||||
function make(cw) {
|
||||
const w = dxfRecipeWizard(1, () => 'tok', {});
|
||||
const vp = () => ({ clientWidth: cw, clientHeight: 238, scrollLeft: 5, scrollTop: 5 });
|
||||
w.$refs = { vpImg: vp(), vpDxf: vp() };
|
||||
w.$nextTick = fn => fn();
|
||||
return w;
|
||||
}
|
||||
|
||||
// img: viewport 400 x 238 utili, immagine 2856 x 2848
|
||||
let w = make(400);
|
||||
w.vSetContent('img', 2856, 2848);
|
||||
check('#113 img: Adatta = clientHeight/2848', near(w.views.img.zoom, 238 / 2848) && near(w.views.img.fit, 238 / 2848), String(w.views.img.zoom));
|
||||
check('#113 img: scroll azzerato dopo Adatta', w.$refs.vpImg.scrollLeft === 0 && w.$refs.vpImg.scrollTop === 0);
|
||||
check('#113 img: percentuale = zoom*100', w.vPct('img') === Math.round(238 / 2848 * 100) + ' %', w.vPct('img'));
|
||||
const z0 = w.views.img.zoom;
|
||||
w.vZoomBy('img', 1.25);
|
||||
check('#113 img: zoomBy moltiplica', near(w.views.img.zoom, z0 * 1.25));
|
||||
check('#113 img: dopo zoom si puo trascinare', w.vCanPan('img') === true && w.vCursor('img') === 'grab');
|
||||
w.vZoomBy('img', 1e6);
|
||||
check('#113 img: limite alto 8', w.views.img.zoom === 8);
|
||||
w.vZoomBy('img', 1e-9);
|
||||
check('#113 img: limite basso 0.05', w.views.img.zoom === 0.05);
|
||||
w.vFit('img');
|
||||
check('#113 img: Adatta torna al fit', near(w.views.img.zoom, z0) && w.vCanPan('img') === false);
|
||||
// clientHeight 0 (non misurabile): ripiego 240
|
||||
w = make(400); w.$refs.vpImg.clientHeight = 0; w.vSetContent('img', 2856, 2848);
|
||||
check('#113 img: clientHeight 0 usa 240', near(w.views.img.fit, 240 / 2848));
|
||||
w = make(400); w.vSetContent('img', 2856, 2848);
|
||||
check('#113 img: l\'altra vista non si muove', w.views.dxf.w === 0 && w.views.dxf.zoom === 1);
|
||||
check('#113 img: wrapper w*zoom', near(w.vW('img'), 2856 * z0) && near(w.vH('img'), 2848 * z0));
|
||||
|
||||
// dxf: viewBox 20 x 10 mm, viewport 400 x 238 -> fit = min(20, 23.8) = 20
|
||||
w = make(400);
|
||||
w.vSetContent('dxf', 20, 10);
|
||||
check('#113 dxf: fit = min(vpW/w, clientHeight/h)', near(w.views.dxf.fit, 20), String(w.views.dxf.fit));
|
||||
check('#113 dxf: dopo Adatta 100 %', w.vPct('dxf') === '100 %', w.vPct('dxf'));
|
||||
w.vZoomBy('dxf', 2);
|
||||
check('#113 dxf: zoom 2x = 200 %', w.vPct('dxf') === '200 %', w.vPct('dxf'));
|
||||
w.vZoomBy('dxf', 1e9);
|
||||
check('#113 dxf: si ferma a fit*50', near(w.views.dxf.zoom, 20 * 50) && w.vPct('dxf') === '5000 %', String(w.views.dxf.zoom));
|
||||
w.vZoomBy('dxf', 1e-9);
|
||||
check('#113 dxf: si ferma a fit*0.5', near(w.views.dxf.zoom, 10) && w.vPct('dxf') === '50 %', String(w.views.dxf.zoom));
|
||||
|
||||
// vFit con viewport largo 0 (nascosto): stato invariato
|
||||
w = make(0);
|
||||
w.vSetContent('img', 1000, 500);
|
||||
check('#113 clientWidth 0: nessun fit, stato invariato', w.views.img.zoom === 1 && w.views.img.fit === 1 && w.views.img.w === 1000);
|
||||
w.$refs.vpImg.clientWidth = 500;
|
||||
w.vFit('img');
|
||||
check('#113 viewport visibile: il fit parte', near(w.views.img.fit, 238 / 500), String(w.views.img.fit));
|
||||
|
||||
// Reset: nuova immagine / DXF tolto
|
||||
w.vReset('img');
|
||||
check('#113 reset: contenuto e zoom azzerati', w.views.img.w === 0 && w.views.img.zoom === 1 && w.vCanPan('img') === false);
|
||||
w.vZoomBy('img', 2);
|
||||
check('#113 zoomBy senza contenuto: ignorato', w.views.img.zoom === 1);
|
||||
|
||||
// Integrazione: onImagePreviewLoad e _setImage
|
||||
global.URL.createObjectURL = () => 'blob:x';
|
||||
global.URL.revokeObjectURL = () => {};
|
||||
w = make(400);
|
||||
w._setImage({ name: 'a.bmp' }, 'file');
|
||||
w.onImagePreviewLoad({ naturalWidth: 800, naturalHeight: 240 });
|
||||
check('#113 onImagePreviewLoad: Adatta all\'arrivo', near(w.views.img.zoom, 0.5) && w.views.img.w === 800, String(w.views.img.zoom));
|
||||
w._setImage({ name: 'b.bmp' }, 'file');
|
||||
check('#113 _setImage: vista azzerata', w.views.img.w === 0 && w.views.img.zoom === 1);
|
||||
w.onImagePreviewLoad({ naturalWidth: 800, naturalHeight: 240 });
|
||||
w.onImagePreviewError();
|
||||
check('#113 errore <img>: vista azzerata', w.views.img.w === 0);
|
||||
|
||||
// Wheel: solo con Ctrl
|
||||
w = make(400);
|
||||
w.vSetContent('dxf', 20, 10);
|
||||
let prevented = false;
|
||||
w.vWheel('dxf', { ctrlKey: false, deltaY: -1, preventDefault() { prevented = true; } });
|
||||
check('#113 rotella senza Ctrl: niente zoom', w.views.dxf.zoom === 20 && !prevented);
|
||||
w.vWheel('dxf', { ctrlKey: true, deltaY: -1, preventDefault() { prevented = true; } });
|
||||
check('#113 Ctrl+rotella: zoom avanti', near(w.views.dxf.zoom, 25) && prevented);
|
||||
|
||||
// Pan
|
||||
w = make(400);
|
||||
w.vSetContent('dxf', 20, 10);
|
||||
w.vZoomBy('dxf', 2);
|
||||
const vp = w.$refs.vpDxf;
|
||||
vp.scrollLeft = 100; vp.scrollTop = 50; vp.setPointerCapture = () => {};
|
||||
w.vPanDown('dxf', { pointerType: 'mouse', button: 0, clientX: 200, clientY: 100, pointerId: 1 });
|
||||
w.vPanMove('dxf', { clientX: 150, clientY: 90 });
|
||||
check('#113 pan: scroll di quanto si muove il puntatore', vp.scrollLeft === 150 && vp.scrollTop === 60, vp.scrollLeft + ',' + vp.scrollTop);
|
||||
check('#113 pan: cursore grabbing', w.vCursor('dxf') === 'grabbing');
|
||||
w.vPanEnd();
|
||||
check('#113 pan finito', w.viewPanKey === '' && w._viewPan === null);
|
||||
|
||||
// #113: Adatta da ingrandito, con barre di scorrimento presenti: area = offset* meno i bordi, non client*
|
||||
global.getComputedStyle = () => ({ borderLeftWidth: '1px', borderRightWidth: '1px', borderTopWidth: '1px', borderBottomWidth: '1px' });
|
||||
w = make(432); w.$refs.vpImg.clientHeight = 228; w.$refs.vpImg.offsetWidth = 444; w.$refs.vpImg.offsetHeight = 240;
|
||||
w.vSetContent('img', 2464, 2056);
|
||||
check('#113 Adatta con barre: area senza barre (238/2056)', near(w.views.img.fit, 238 / 2056), String(w.views.img.fit));
|
||||
delete global.getComputedStyle;
|
||||
|
||||
// Integrazione DXF: loadDxfPreview imposta la misura del viewBox e fa Adatta; file tolto azzera
|
||||
(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 = make(400);
|
||||
wd.dxfFile = { name: 'a.dxf' };
|
||||
await wd.loadDxfPreview();
|
||||
const vb = wd.dxfPreviewViewBox().split(' ').map(Number);
|
||||
check('#113 loadDxfPreview: misura = viewBox', near(wd.views.dxf.w, vb[2]) && near(wd.views.dxf.h, vb[3]), JSON.stringify(wd.views.dxf));
|
||||
check('#113 loadDxfPreview: Adatta = 100 %', wd.vPct('dxf') === '100 %' && wd.views.dxf.fit > 1);
|
||||
wd.dxfFile = null;
|
||||
await wd.loadDxfPreview();
|
||||
check('#113 DXF tolto: vista azzerata', wd.views.dxf.w === 0 && wd.views.dxf.zoom === 1);
|
||||
|
||||
if (failures) { console.log(failures + ' test falliti'); process.exit(1); }
|
||||
console.log('tutti i test #113 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,49 @@
|
||||
/**
|
||||
* #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 = {};
|
||||
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 : '')); }
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
process.exit(failures ? 1 : 0);
|
||||
@@ -278,7 +278,7 @@ def test_camera_sources_proxy_keeps_only_the_three_fields(logged_in_client, mock
|
||||
"name": "camera:folder", "label": "Cartella", "kind": "folder"}
|
||||
assert len(resp.get_json()["sources"]) == 2
|
||||
assert get.call_args.args[0] == "http://acq:8765/camera/sources"
|
||||
assert get.call_args.kwargs["timeout"] == 5
|
||||
assert get.call_args.kwargs["timeout"] == 15 # #106
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", ["ftp://x", "", "http://", "acq:8765"])
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -72,6 +72,7 @@ class TestStationCameraGrab:
|
||||
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)
|
||||
@@ -178,6 +179,12 @@ class TestDxfCompareProxy:
|
||||
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")
|
||||
@@ -204,6 +211,122 @@ class TestDxfCompareProxy:
|
||||
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:
|
||||
@@ -229,4 +352,21 @@ class TestPages:
|
||||
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 and "zoomBy(" in html and "fit()" in html # #104: mm/px solo da file, zoom e Fit
|
||||
assert 'onPanDown($event)' in html # #104: pan col puntatore
|
||||
assert "imagePreviewUrl" in html and "imagePreviewSummary" in html # #102: anteprima immagine
|
||||
# #113: zoom, Adatta e pan anche sulle due anteprime (immagine e DXF), per chiave
|
||||
assert 'x-ref="vpImg"' in html and 'x-ref="vpDxf"' in html
|
||||
assert "vFit('img')" in html and "vFit('dxf')" in html
|
||||
assert "vZoomBy('img', 1.25)" in html and "vZoomBy('dxf', 1.25)" in html
|
||||
assert "vPanDown('img', $event)" in html and "vWheel('dxf', $event)" in html
|
||||
# #113: Alpine :style a stringa sostituisce l'attributo style: niente style statico sullo stesso tag
|
||||
assert 'style="margin:auto;flex:none;"' not in html
|
||||
assert html.count("margin:auto;flex:none;") == 2 and html.count(":style=\"'margin:auto;flex:none;") == 2
|
||||
# #113: x-show e :style (display:flex) si pestano: x-ref="vpImg" non sta sul tag con x-show
|
||||
tag = html[html.rindex("<div", 0, html.index('x-ref="vpImg"')):html.index('x-ref="vpImg"')]
|
||||
assert "x-show" not in tag
|
||||
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}"
|
||||
)
|
||||
@@ -313,6 +313,10 @@ class TestImageCropInclusion:
|
||||
html = logged_in_client.get("/maker/recipes/3/tasks").get_data(as_text=True)
|
||||
assert "js/image-crop.js" 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):
|
||||
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)
|
||||
assert "js/image-crop.js" 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"] == {
|
||||
"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):
|
||||
|
||||
@@ -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
|
||||
@@ -167,3 +167,11 @@ class TestRunTaskResultsProxy:
|
||||
assert 'id="run-task-results"' in html
|
||||
assert "loadTaskResults" 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,54 @@
|
||||
"""gitea:Adriano/TieMeasureFlow#96 - l'immagine del task visione sta in fit, senza scorrimento.
|
||||
|
||||
Controllo sulle classi del template: pannello overflow-hidden, img/canvas
|
||||
object-contain sovrapposti, nessun h-auto sull'immagine (era la causa dello scorrimento).
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
TEMPLATE = Path(__file__).resolve().parents[1] / "templates" / "measure" / "task_execute.html"
|
||||
|
||||
|
||||
def _panel():
|
||||
src = TEMPLATE.read_text(encoding="utf-8")
|
||||
start = src.rindex("<div", 0, src.index('x-data="visionExecute()"'))
|
||||
end = src.index("MEASUREMENT TIMER BANNER")
|
||||
return src[start:end]
|
||||
|
||||
|
||||
def _tag_with(panel, marker):
|
||||
# Il tag (aperto) che contiene il marker; i '>' dentro le virgolette (es. "=>") non lo chiudono.
|
||||
pos = panel.index(marker)
|
||||
i = panel.rindex("<", 0, pos)
|
||||
quote = None
|
||||
for j in range(i, len(panel)):
|
||||
c = panel[j]
|
||||
if quote:
|
||||
quote = None if c == quote else quote
|
||||
elif c in "\"'":
|
||||
quote = c
|
||||
elif c == ">":
|
||||
return panel[i:j + 1]
|
||||
raise AssertionError(marker)
|
||||
|
||||
|
||||
def test_panel_does_not_scroll():
|
||||
tag = _tag_with(_panel(), 'x-data="visionExecute()"')
|
||||
assert "overflow-hidden" in tag and "min-h-0" in tag
|
||||
assert "overflow-auto" not in tag
|
||||
|
||||
|
||||
def test_image_is_object_contain_without_h_auto():
|
||||
panel = _panel()
|
||||
img = _tag_with(panel, 'x-ref="img"')
|
||||
assert "object-contain" in img
|
||||
assert "h-auto" not in img
|
||||
canvas = _tag_with(panel, 'x-ref="overlay"')
|
||||
assert "object-contain" in canvas # stesso fit dell'img: overlay allineato
|
||||
|
||||
|
||||
def test_image_box_and_outcome_column_can_shrink():
|
||||
panel = _panel()
|
||||
box = _tag_with(panel, 'x-show="shownImage"')
|
||||
assert "flex-1" in box and "min-h-0" in box
|
||||
outcome = _tag_with(panel, 'x-show="result">')
|
||||
assert "overflow-y-auto" in outcome and "min-h-0" in outcome
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -3485,3 +3485,106 @@ msgstr "Invalid camera address"
|
||||
#: templates/admin/stations.html blueprints/admin.py (#88)
|
||||
msgid "Risposta non valida dal servizio camera"
|
||||
msgstr "Invalid response from the camera service"
|
||||
|
||||
#: templates/statistics/dashboard.html (#100)
|
||||
msgid "Scegli un punto per carta di controllo e istogramma"
|
||||
msgstr "Choose a point for the control chart and histogram"
|
||||
|
||||
#: templates/maker/task_editor.html blueprints/maker.py (#101)
|
||||
msgid "Serve il file DXF"
|
||||
msgstr "The DXF file is required"
|
||||
|
||||
msgid "Lettura DXF…"
|
||||
msgstr "Reading DXF…"
|
||||
|
||||
msgid "entità"
|
||||
msgstr "entities"
|
||||
|
||||
#: templates/maker/task_editor.html (#102)
|
||||
msgid "campo"
|
||||
msgstr "field"
|
||||
|
||||
#: templates/maker/task_editor.html (#102)
|
||||
msgid "Anteprima non disponibile per questo formato"
|
||||
msgstr "Preview not available for this format"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "Riduci"
|
||||
msgstr "Zoom out"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "Ingrandisci"
|
||||
msgstr "Zoom in"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "Adatta"
|
||||
msgstr "Fit"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "Scala"
|
||||
msgstr "Scale"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "Punti in banda"
|
||||
msgstr "Points in band"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "Distanza media"
|
||||
msgstr "Average distance"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "Distanza minima"
|
||||
msgstr "Minimum distance"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "Distanza massima"
|
||||
msgstr "Maximum distance"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "Conforme"
|
||||
msgstr "Pass"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "Con avvisi"
|
||||
msgstr "Warning"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "Non conforme"
|
||||
msgstr "Fail"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "mm/px a mano"
|
||||
msgstr "manual mm/px"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "calibrazione stazione"
|
||||
msgstr "station calibration"
|
||||
|
||||
#: templates/maker/task_editor.html blueprints/maker.py (#105)
|
||||
msgid "Modifica ricetta da DXF"
|
||||
msgstr "Edit recipe from DXF"
|
||||
|
||||
#: templates/maker/task_editor.html blueprints/maker.py (#105)
|
||||
msgid "Immagine di riferimento non salvata (ricetta creata prima): acquisiscila o caricala"
|
||||
msgstr "Reference image not saved (recipe created earlier): capture or upload it"
|
||||
|
||||
#: templates/maker/task_editor.html blueprints/maker.py (#105)
|
||||
msgid "Impossibile leggere i file della ricetta esistente"
|
||||
msgstr "Cannot read the files of the existing recipe"
|
||||
|
||||
#: templates/maker/task_editor.html blueprints/maker.py (#105)
|
||||
msgid "Ricetta esistente caricata: Prova e poi Salva ricetta per sostituirla"
|
||||
msgstr "Existing recipe loaded: Test, then Save recipe to replace it"
|
||||
|
||||
#: templates/maker/task_editor.html blueprints/maker.py (#105)
|
||||
msgid "File non trovato"
|
||||
msgstr "File not found"
|
||||
|
||||
#: templates/maker/task_editor.html blueprints/maker.py (#105)
|
||||
#, python-format
|
||||
msgid "Servizio non raggiungibile: %(detail)s"
|
||||
msgstr "Service not reachable: %(detail)s"
|
||||
|
||||
#: templates/maker/task_editor.html templates/maker/recipe_editor.html (#107)
|
||||
msgid "Formato immagine non leggibile. Usa PNG, JPG o PDF."
|
||||
msgstr "Unreadable image format. Use PNG, JPG or PDF."
|
||||
|
||||
@@ -3473,3 +3473,102 @@ msgstr "Indirizzo della camera non valido"
|
||||
#: templates/admin/stations.html blueprints/admin.py (#88)
|
||||
msgid "Risposta non valida dal servizio camera"
|
||||
msgstr "Risposta non valida dal servizio camera"
|
||||
|
||||
#: templates/maker/task_editor.html blueprints/maker.py (#101)
|
||||
msgid "Serve il file DXF"
|
||||
msgstr "Serve il file DXF"
|
||||
|
||||
msgid "Lettura DXF…"
|
||||
msgstr "Lettura DXF…"
|
||||
|
||||
msgid "entità"
|
||||
msgstr "entità"
|
||||
|
||||
#: templates/maker/task_editor.html (#102)
|
||||
msgid "campo"
|
||||
msgstr "campo"
|
||||
|
||||
#: templates/maker/task_editor.html (#102)
|
||||
msgid "Anteprima non disponibile per questo formato"
|
||||
msgstr "Anteprima non disponibile per questo formato"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "Riduci"
|
||||
msgstr "Riduci"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "Ingrandisci"
|
||||
msgstr "Ingrandisci"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "Adatta"
|
||||
msgstr "Adatta"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "Scala"
|
||||
msgstr "Scala"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "Punti in banda"
|
||||
msgstr "Punti in banda"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "Distanza media"
|
||||
msgstr "Distanza media"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "Distanza minima"
|
||||
msgstr "Distanza minima"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "Distanza massima"
|
||||
msgstr "Distanza massima"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "Conforme"
|
||||
msgstr "Conforme"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "Con avvisi"
|
||||
msgstr "Con avvisi"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "Non conforme"
|
||||
msgstr "Non conforme"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "mm/px a mano"
|
||||
msgstr "mm/px a mano"
|
||||
|
||||
#: templates/maker/task_editor.html (#104)
|
||||
msgid "calibrazione stazione"
|
||||
msgstr "calibrazione stazione"
|
||||
|
||||
#: templates/maker/task_editor.html blueprints/maker.py (#105)
|
||||
msgid "Modifica ricetta da DXF"
|
||||
msgstr "Modifica ricetta da DXF"
|
||||
|
||||
#: templates/maker/task_editor.html blueprints/maker.py (#105)
|
||||
msgid "Immagine di riferimento non salvata (ricetta creata prima): acquisiscila o caricala"
|
||||
msgstr "Immagine di riferimento non salvata (ricetta creata prima): acquisiscila o caricala"
|
||||
|
||||
#: templates/maker/task_editor.html blueprints/maker.py (#105)
|
||||
msgid "Impossibile leggere i file della ricetta esistente"
|
||||
msgstr "Impossibile leggere i file della ricetta esistente"
|
||||
|
||||
#: templates/maker/task_editor.html blueprints/maker.py (#105)
|
||||
msgid "Ricetta esistente caricata: Prova e poi Salva ricetta per sostituirla"
|
||||
msgstr "Ricetta esistente caricata: Prova e poi Salva ricetta per sostituirla"
|
||||
|
||||
#: templates/maker/task_editor.html blueprints/maker.py (#105)
|
||||
msgid "File non trovato"
|
||||
msgstr "File non trovato"
|
||||
|
||||
#: templates/maker/task_editor.html blueprints/maker.py (#105)
|
||||
#, python-format
|
||||
msgid "Servizio non raggiungibile: %(detail)s"
|
||||
msgstr "Servizio non raggiungibile: %(detail)s"
|
||||
|
||||
#: templates/maker/task_editor.html templates/maker/recipe_editor.html (#107)
|
||||
msgid "Formato immagine non leggibile. Usa PNG, JPG o PDF."
|
||||
msgstr "Formato immagine non leggibile. Usa PNG, JPG o PDF."
|
||||
|
||||
@@ -184,6 +184,66 @@ def _candidate_anchors(gray: np.ndarray, dxf_path: Path, px_per_mm: float,
|
||||
return anchors
|
||||
|
||||
|
||||
def chiudi_contorni(dxf_bytes: bytes) -> tuple[bytes, dict]:
|
||||
"""#103: unisce le entita' aperte (LINE/ARC/polilinee aperte in fila) in contorni chiusi.
|
||||
|
||||
CompareDxf taglia il contorno misurato agli estremi di ogni entita' aperta
|
||||
del disegno (cut_open_ends): con un profilo spezzato in LINE+ARC e banda
|
||||
larga non resta nessun punto in banda e il confronto fallisce. Qui le catene
|
||||
chiuse diventano una LWPOLYLINE chiusa (con bulge per gli archi).
|
||||
|
||||
Pura e senza eccezioni nuove: se non c'e' nulla da unire, o ezdxf non legge
|
||||
il file, ritorna i byte originali (sha invariato, ricette esistenti stabili).
|
||||
Ritorna (bytes, {"anelli", "entita_unite", "entita_libere"}).
|
||||
"""
|
||||
nulla = {"anelli": 0, "entita_unite": 0, "entita_libere": 0}
|
||||
try:
|
||||
import ezdxf
|
||||
from ezdxf import edgeminer as em, edgesmith as es
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
src = Path(tmp) / "in.dxf"
|
||||
src.write_bytes(dxf_bytes)
|
||||
doc = ezdxf.readfile(str(src))
|
||||
msp = doc.modelspace()
|
||||
# edges_from_entities_2d salta le entita' gia' chiuse (restano dove sono)
|
||||
edges = list(es.edges_from_entities_2d(msp))
|
||||
if not edges:
|
||||
return dxf_bytes, nulla
|
||||
loops = em.find_all_loops(em.Deposit(edges, gap_tol=0.01)) # 0.01 mm: giunzioni CAD
|
||||
usati: set = set()
|
||||
unite: list = [] # (polilinea, entita' di partenza)
|
||||
# loop piu' lungo per primo, ogni edge usato una sola volta
|
||||
for loop in sorted(loops, key=lambda lp: -sum(e.length for e in lp)):
|
||||
ids = {e.id for e in loop}
|
||||
if ids & usati:
|
||||
continue
|
||||
usati |= ids
|
||||
unite.append((loop, es.lwpolyline_from_chain(loop)))
|
||||
if not unite:
|
||||
return dxf_bytes, {**nulla, "entita_libere": len(edges)}
|
||||
entita_unite = 0
|
||||
for loop, pl in unite:
|
||||
pl.closed = True
|
||||
payload = getattr(loop[0], "payload", None)
|
||||
layer = getattr(getattr(payload, "dxf", None), "layer", None)
|
||||
if layer:
|
||||
pl.dxf.layer = layer # stesso layer dell'entita' di partenza
|
||||
for e in loop:
|
||||
ent = getattr(e, "payload", None)
|
||||
if ent is not None and ent.is_alive:
|
||||
msp.delete_entity(ent) # sostituita dalla polilinea chiusa
|
||||
msp.add_foreign_entity(pl)
|
||||
entita_unite += len(loop)
|
||||
dst = Path(tmp) / "out.dxf"
|
||||
doc.saveas(str(dst))
|
||||
info = {"anelli": len(unite), "entita_unite": entita_unite,
|
||||
"entita_libere": len(edges) - entita_unite}
|
||||
return dst.read_bytes(), info
|
||||
except Exception: # noqa: BLE001 - DXF illeggibile: l'errore resta quello a valle
|
||||
return dxf_bytes, nulla
|
||||
|
||||
|
||||
def _run(gray, graph, px_per_mm, folder) -> RunOutcome:
|
||||
return run_graph(gray, graph, calibration={"px_per_mm": px_per_mm}, recipe_dir=folder)
|
||||
|
||||
@@ -211,6 +271,9 @@ def build_dxf_compare(
|
||||
raise CostruttoreError(f"polarity deve essere una di {list(_POLARITIES)}")
|
||||
if not dxf_bytes:
|
||||
raise CostruttoreError("DXF vuoto")
|
||||
# #103: entita' aperte unite in contorni chiusi PRIMA dello sha: disegno.dxf,
|
||||
# grafo e runtime usano tutti il DXF chiuso
|
||||
dxf_bytes, _info_chiusura = chiudi_contorni(dxf_bytes)
|
||||
|
||||
frame = _gray_u8(gray)
|
||||
calibration_bytes = json.dumps({"px_per_mm": px_per_mm}).encode("utf-8")
|
||||
|
||||
+18
-1
@@ -217,6 +217,14 @@ def _is_number(value: Any) -> bool:
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||
|
||||
|
||||
# #112: quantity/unit delle uscite che il tool non dichiara in value_quantities
|
||||
_ETICHETTE_EXTRA = {
|
||||
"count": ("count", "pz"),
|
||||
"n_points": ("count", "punti"),
|
||||
"area": ("area", "px²"),
|
||||
}
|
||||
|
||||
|
||||
def validate_graph(
|
||||
graph: dict, recipe_dir: Any = None
|
||||
) -> tuple[bool, list[str], list[dict]]:
|
||||
@@ -245,7 +253,11 @@ def validate_graph(
|
||||
for prop in tool.outputs():
|
||||
if not (_is_number(prop.default) or prop.name in values):
|
||||
continue
|
||||
quantity = value_quantities.get(prop.name, getattr(tool, "quantity", None))
|
||||
if prop.name in values:
|
||||
quantity = value_quantities.get(prop.name, getattr(tool, "quantity", None))
|
||||
else:
|
||||
# #112: fuori da values mai ereditare tool.quantity (count/area non sono lunghezze)
|
||||
quantity = value_quantities.get(prop.name)
|
||||
if not isinstance(quantity, str):
|
||||
quantity = None
|
||||
unit = None
|
||||
@@ -254,6 +266,11 @@ def validate_graph(
|
||||
unit = nominal_unit if isinstance(nominal_unit, str) else None
|
||||
elif quantity == "angle":
|
||||
unit = "deg"
|
||||
elif quantity == "ratio":
|
||||
unit = "%" # #112: rapporti (points_in_pct) in percentuale
|
||||
if quantity is None and prop.name not in values:
|
||||
# #112: etichette delle uscite note non dichiarate dal tool
|
||||
quantity, unit = _ETICHETTE_EXTRA.get(prop.name, (None, None))
|
||||
outputs.append({
|
||||
"key": f"{tool.id}.{prop.name}",
|
||||
"quantity": quantity,
|
||||
|
||||
@@ -288,3 +288,28 @@ def test_validate_reports_a_missing_file(tmp_path):
|
||||
ok, errors, outputs = runner.validate_graph(_dxf_recipe(), tmp_path)
|
||||
|
||||
assert not ok and errors and outputs == []
|
||||
|
||||
|
||||
def test_validate_labels_wizard_outputs(tmp_path):
|
||||
"""#112: count/n_points/area non ereditano la quantita' del tool, ratio in %."""
|
||||
from src.vision import costruttori
|
||||
|
||||
dxf_bytes = _SAMPLE_DXF.read_bytes()
|
||||
cal_bytes = b'{"px_per_mm": 4.0}'
|
||||
(tmp_path / costruttori.DXF_FILE).write_bytes(dxf_bytes)
|
||||
(tmp_path / costruttori.CALIBRATION_FILE).write_bytes(cal_bytes)
|
||||
graph = costruttori._graph(
|
||||
hashlib.sha256(dxf_bytes).hexdigest(), hashlib.sha256(cal_bytes).hexdigest(),
|
||||
1.0, 95.0, "dark", {"row": 0.0, "col": 0.0, "angle_deg": 0.0},
|
||||
(0.0, 0.0, 0.0),
|
||||
)
|
||||
|
||||
ok, errors, outputs = runner.validate_graph(graph, tmp_path)
|
||||
|
||||
assert ok, errors
|
||||
by_key = {o["key"]: (o["quantity"], o["unit"]) for o in outputs}
|
||||
assert by_key["pezzo.count"] == ("count", "pz")
|
||||
assert by_key["pezzo.area"] == ("area", "px²")
|
||||
assert by_key["dxf.n_points"] == ("count", "punti")
|
||||
assert by_key["dxf.points_in_pct"] == ("ratio", "%")
|
||||
assert by_key["dxf.distance_max"] == ("length", "mm")
|
||||
|
||||
@@ -11,13 +11,14 @@ import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile # #101
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile, status
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
|
||||
from src.vision.costruttori import CostruttoreError, build_dxf_compare # #83
|
||||
from src.vision.costruttori import CostruttoreError, build_dxf_compare, chiudi_contorni # #83 #103
|
||||
from src.vision.runner import (
|
||||
GraphBuildError,
|
||||
RunOutcome,
|
||||
@@ -288,3 +289,91 @@ async def recipes_dxf_compare(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"recipe build failed: {type(exc).__name__}: {exc}",
|
||||
) from exc
|
||||
|
||||
|
||||
# #101: tetto ai punti dell'anteprima DXF (un profilo fitto non deve gonfiare
|
||||
# la risposta ne' rallentare il disegno SVG nel browser).
|
||||
_PREVIEW_MAX_POINTS = 20000
|
||||
|
||||
|
||||
def _decimate(points: list[list[float]], keep: int) -> list[list[float]]:
|
||||
"""Decimazione uniforme a `keep` punti (>= 2), primo e ultimo sempre inclusi."""
|
||||
n = len(points)
|
||||
if n <= keep or keep < 2:
|
||||
return points
|
||||
idx = sorted({round(i * (n - 1) / (keep - 1)) for i in range(keep)})
|
||||
return [points[i] for i in idx]
|
||||
|
||||
|
||||
def _dxf_preview(dxf_bytes: bytes) -> dict:
|
||||
"""#101: legge il DXF con lo stesso lettore di CompareDxf (read_xld_dxf) e ne
|
||||
ritorna i contorni in mm, asse y verso il basso (come immagine e SVG).
|
||||
|
||||
Funzione sincrona e pura: la rotta la esegue in un thread. Solleva
|
||||
CostruttoreError per DXF vuoto, illeggibile o senza geometria.
|
||||
"""
|
||||
if not dxf_bytes:
|
||||
raise CostruttoreError("DXF illeggibile: file vuoto")
|
||||
from visionsuite.io.dxf import read_xld_dxf
|
||||
# #103: l'anteprima mostra cio' che il confronto usera' (contorni chiusi)
|
||||
dxf_bytes, _ = chiudi_contorni(dxf_bytes)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "disegno.dxf"
|
||||
path.write_bytes(dxf_bytes)
|
||||
try:
|
||||
# flip_y=False + scale=1: coordinate native del DXF, in mm; il
|
||||
# ribaltamento dell'asse y lo facciamo noi sotto (y = -y_dxf)
|
||||
conts = list(read_xld_dxf(str(path), sampling_distance=0.05,
|
||||
scale=1.0, flip_y=False))
|
||||
except Exception as exc: # noqa: BLE001 - qualunque errore di lettura = DXF illeggibile
|
||||
raise CostruttoreError(f"DXF illeggibile: {type(exc).__name__}: {exc}") from exc
|
||||
|
||||
contorni: list[list[list[float]]] = []
|
||||
for cont in conts:
|
||||
pts = np.asarray(cont.points, dtype=float) # (riga, colonna) = (y_dxf, x_dxf)
|
||||
if pts.ndim != 2 or pts.shape[0] < 2:
|
||||
continue
|
||||
line = [[float(c), float(-r)] for r, c in pts]
|
||||
if cont.closed and line[0] != line[-1]:
|
||||
line.append(list(line[0])) # chiude il giro anche nella polilinea SVG
|
||||
contorni.append(line)
|
||||
if not contorni:
|
||||
raise CostruttoreError("DXF senza geometria")
|
||||
|
||||
# #101: le dimensioni si misurano sui punti completi, prima della decimazione
|
||||
xs = [p[0] for c in contorni for p in c]
|
||||
ys = [p[1] for c in contorni for p in c]
|
||||
xmin, xmax, ymin, ymax = min(xs), max(xs), min(ys), max(ys)
|
||||
|
||||
total = len(xs)
|
||||
if total > _PREVIEW_MAX_POINTS:
|
||||
# quota per contorno proporzionale, minimo 2 punti
|
||||
contorni = [
|
||||
_decimate(c, max(2, int(len(c) * _PREVIEW_MAX_POINTS / total))) for c in contorni
|
||||
]
|
||||
contorni = [[[round(x, 3), round(y, 3)] for x, y in c] for c in contorni]
|
||||
xmin, xmax, ymin, ymax = (round(v, 3) for v in (xmin, xmax, ymin, ymax))
|
||||
return {
|
||||
"contorni": contorni,
|
||||
"bbox": {"xmin": xmin, "ymin": ymin, "xmax": xmax, "ymax": ymax},
|
||||
"larghezza_mm": round(xmax - xmin, 3),
|
||||
"altezza_mm": round(ymax - ymin, 3),
|
||||
"entita": len(contorni),
|
||||
"punti": sum(len(c) for c in contorni),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/dxf/preview")
|
||||
async def dxf_preview(dxf: UploadFile = File(...)) -> dict:
|
||||
"""#101: anteprima del disegno DXF (contorni + dimensioni in mm) per il
|
||||
wizard «Crea ricetta da DXF». Solo lettura, leggera: nessun run slot."""
|
||||
dxf_bytes = await dxf.read()
|
||||
try:
|
||||
return await asyncio.to_thread(_dxf_preview, dxf_bytes)
|
||||
except CostruttoreError as exc:
|
||||
raise _unprocessable(str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"dxf preview failed: {type(exc).__name__}: {exc}",
|
||||
) from exc
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""#103: chiudi_contorni unisce LINE/ARC aperti in contorni chiusi (test sincroni, no pytest-asyncio)."""
|
||||
import hashlib
|
||||
import io
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import ezdxf
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from src.vision.costruttori import CostruttoreError, build_dxf_compare, chiudi_contorni
|
||||
from src.vision_worker.main import _dxf_preview
|
||||
|
||||
|
||||
def _dxf(riempi) -> bytes:
|
||||
doc = ezdxf.new("R2010")
|
||||
riempi(doc.modelspace())
|
||||
buf = io.StringIO()
|
||||
doc.write(buf)
|
||||
return buf.getvalue().encode("utf-8")
|
||||
|
||||
|
||||
def _quattro_line(msp, w=20.0, h=10.0):
|
||||
pts = [(0, 0), (w, 0), (w, h), (0, h)]
|
||||
for i in range(4):
|
||||
msp.add_line(pts[i], pts[(i + 1) % 4])
|
||||
|
||||
|
||||
def _entita(dxf: bytes):
|
||||
with tempfile.TemporaryDirectory() as tmp: # readfile gestisce da solo codifica e CRLF
|
||||
path = Path(tmp) / "o.dxf"
|
||||
path.write_bytes(dxf)
|
||||
return list(ezdxf.readfile(str(path)).modelspace())
|
||||
|
||||
|
||||
def test_chiudi_rettangolo_di_4_line_in_una_polilinea_chiusa():
|
||||
out, info = chiudi_contorni(_dxf(_quattro_line))
|
||||
assert info == {"anelli": 1, "entita_unite": 4, "entita_libere": 0}
|
||||
ents = _entita(out)
|
||||
assert [e.dxftype() for e in ents] == ["LWPOLYLINE"] and ents[0].closed
|
||||
xs = [p[0] for p in ents[0].get_points()]
|
||||
ys = [p[1] for p in ents[0].get_points()]
|
||||
assert (min(xs), max(xs), min(ys), max(ys)) == (0, 20, 0, 10) # bbox invariato
|
||||
|
||||
|
||||
def test_chiudi_asola_line_e_arc_ha_bulge():
|
||||
def asola(msp): # asola 20x10: due LINE orizzontali + due semicerchi r=5
|
||||
msp.add_line((5, 0), (15, 0))
|
||||
msp.add_arc((15, 5), 5, -90, 90)
|
||||
msp.add_line((15, 10), (5, 10))
|
||||
msp.add_arc((5, 5), 5, 90, 270)
|
||||
out, info = chiudi_contorni(_dxf(asola))
|
||||
assert info["anelli"] == 1 and info["entita_unite"] == 4
|
||||
ents = _entita(out)
|
||||
assert len(ents) == 1 and ents[0].closed
|
||||
assert any(abs(p[4]) > 1e-6 for p in ents[0].get_points()) # gli archi diventano bulge
|
||||
|
||||
|
||||
def test_dxf_gia_chiuso_resta_identico():
|
||||
def chiuso(msp):
|
||||
msp.add_lwpolyline([(0, 0), (20, 0), (20, 10), (0, 10)], close=True)
|
||||
msp.add_circle((10, 5), 2)
|
||||
dxf = _dxf(chiuso)
|
||||
out, info = chiudi_contorni(dxf)
|
||||
assert out == dxf and info["anelli"] == 0
|
||||
|
||||
|
||||
def test_line_isolata_resta_e_byte_identici():
|
||||
dxf = _dxf(lambda msp: msp.add_line((0, 0), (10, 0)))
|
||||
out, info = chiudi_contorni(dxf)
|
||||
assert out == dxf and info["anelli"] == 0 and info["entita_libere"] == 1
|
||||
|
||||
|
||||
def test_byte_non_dxf_ritornati_identici():
|
||||
brutto = b"questo non e' un dxf"
|
||||
out, info = chiudi_contorni(brutto)
|
||||
assert out == brutto and info["anelli"] == 0
|
||||
|
||||
|
||||
def test_preview_di_quattro_line_e_una_sola_entita():
|
||||
resp = _dxf_preview(_dxf(_quattro_line))
|
||||
assert resp["entita"] == 1 and resp["larghezza_mm"] == 20 and resp["altezza_mm"] == 10
|
||||
|
||||
|
||||
@pytest.mark.parametrize("band", [0.1, 0.3])
|
||||
def test_chiudi_build_dxf_compare_banda_larga_con_4_line(band):
|
||||
# rettangolo scuro 20x10 mm su bianco, 20 px/mm; disegno = 4 LINE aperte
|
||||
ppm = 20.0
|
||||
img = np.full((int(40 * ppm), int(60 * ppm)), 255, np.uint8)
|
||||
cx, cy = img.shape[1] // 2, img.shape[0] // 2
|
||||
cv2.rectangle(img, (cx - int(10 * ppm), cy - int(5 * ppm)),
|
||||
(cx + int(10 * ppm), cy + int(5 * ppm)), 0, -1)
|
||||
dxf = _dxf(_quattro_line)
|
||||
ris = build_dxf_compare(img, dxf, px_per_mm=ppm, band_mm=band)
|
||||
assert ris["outputs"]["dxf.points_in_pct"] > 90.0
|
||||
# il disegno nella ricetta e' quello chiuso, non l'originale
|
||||
import base64
|
||||
salvato = base64.b64decode(ris["files"]["disegno.dxf"])
|
||||
assert salvato != dxf and hashlib.sha256(salvato).hexdigest() == \
|
||||
ris["graph"]["meta"]["dxf"]["disegno"]["sha256"]
|
||||
@@ -80,3 +80,67 @@ async def test_dxf_compare_immagine_non_decodificabile_e_422():
|
||||
async def test_dxf_compare_parametro_fuori_range_e_422():
|
||||
response = await _post(_png(a_scene(0.0)), SAMPLE_DXF.read_bytes(), polarity="grigio")
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
# --- #101: POST /dxf/preview ---------------------------------------------------
|
||||
|
||||
def _rect_dxf(width: float, height: float, y0: float = 3.0) -> bytes:
|
||||
"""DXF minimo con ezdxf: un rettangolo chiuso, y in alto rispetto a 0."""
|
||||
import ezdxf
|
||||
doc = ezdxf.new()
|
||||
doc.modelspace().add_lwpolyline(
|
||||
[(1, y0), (1 + width, y0), (1 + width, y0 + height), (1, y0 + height)], close=True,
|
||||
)
|
||||
buffer = io.StringIO()
|
||||
doc.write(buffer)
|
||||
return buffer.getvalue().encode("utf-8")
|
||||
|
||||
|
||||
def _post_preview(dxf: bytes):
|
||||
# TestClient sincrono: non serve pytest-asyncio (assente nel venv del worker)
|
||||
from fastapi.testclient import TestClient
|
||||
return TestClient(app).post(
|
||||
"/dxf/preview", files={"dxf": ("d.dxf", dxf, "application/octet-stream")},
|
||||
)
|
||||
|
||||
|
||||
def test_dxf_preview_rettangolo_10x5():
|
||||
resp = _post_preview(_rect_dxf(10, 5))
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["larghezza_mm"] == 10 and body["altezza_mm"] == 5
|
||||
assert body["entita"] == 1 and body["punti"] == sum(len(c) for c in body["contorni"])
|
||||
# y ribaltata: il rettangolo stava a y 3..8 nel DXF, ora a -8..-3 (asse verso il basso)
|
||||
assert body["bbox"] == {"xmin": 1, "ymin": -8, "xmax": 11, "ymax": -3}
|
||||
ring = body["contorni"][0]
|
||||
assert ring[0] == ring[-1] # contorno chiuso: primo == ultimo
|
||||
assert all(-8 <= y <= -3 for _, y in ring)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("content", [b"", b"questo non e' un dxf"])
|
||||
def test_dxf_preview_illeggibile_e_422(content):
|
||||
resp = _post_preview(content)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"].startswith("DXF")
|
||||
|
||||
|
||||
def test_dxf_preview_senza_geometria_e_422():
|
||||
import ezdxf
|
||||
doc = ezdxf.new()
|
||||
buffer = io.StringIO()
|
||||
doc.write(buffer)
|
||||
resp = _post_preview(buffer.getvalue().encode("utf-8"))
|
||||
assert resp.status_code == 422
|
||||
assert "senza geometria" in resp.json()["detail"]
|
||||
|
||||
|
||||
def test_dxf_preview_decima_oltre_il_tetto(monkeypatch):
|
||||
from src.vision_worker import main
|
||||
monkeypatch.setattr(main, "_PREVIEW_MAX_POINTS", 50)
|
||||
import ezdxf
|
||||
doc = ezdxf.new()
|
||||
doc.modelspace().add_circle((0, 0), 20)
|
||||
buffer = io.StringIO()
|
||||
doc.write(buffer)
|
||||
full = main._dxf_preview(buffer.getvalue().encode("utf-8"))
|
||||
assert full["punti"] <= 50 + 1 and full["larghezza_mm"] == pytest.approx(40, abs=0.01) # dimensioni dai punti completi
|
||||
|
||||
Reference in New Issue
Block a user