Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 25f8b4d609 | |||
| 552f96bb47 | |||
| 71daab184f | |||
| 9beaf2ed67 | |||
| 800ec41254 | |||
| 9c45a51770 | |||
| 4a48af2ae3 | |||
| b879563738 | |||
| f504c86491 | |||
| 632fd51050 | |||
| 8eb80e20a8 | |||
| b2f82fe779 | |||
| 3c5c5f9bc5 | |||
| d6cee498ad |
+833
-198
File diff suppressed because it is too large
Load Diff
+207
-8
@@ -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
|
||||
@@ -68,6 +72,9 @@ Uso (dalla radice del repo):
|
||||
[--host 127.0.0.1] [--port 8765] [--folder <cartella immagini>] \
|
||||
[--config-dir <cartella calibrazioni>]
|
||||
--folder abilita la sorgente finta `camera:folder` (loop sulle immagini).
|
||||
Immagini caricate (#92): POST/DELETE /camera/immagini, in <config-dir>/immagini; sono sorgenti
|
||||
a pieno titolo (/camera/sources, grab, anteprima, calibrate). POST /camera/anteprima/calibrata
|
||||
raddrizza l'ultimo frame grezzo dell'anteprima con la calibrazione attiva, senza leggerne uno nuovo.
|
||||
Da Docker su Linux: --host 172.17.0.1 (gateway di docker0).
|
||||
|
||||
Dipende da: venv di VisionSuite (`uv sync --all-packages` in vendor/visionsuite):
|
||||
@@ -81,9 +88,13 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import math # #95: mm/px finito nella calibrazione manuale
|
||||
import os
|
||||
import re # #92: nome sicuro per le immagini caricate
|
||||
import sys
|
||||
import threading
|
||||
import time # #92: scadenza della cache delle camere
|
||||
import urllib.parse # #92: nome calibrazione nell'header X-Calibrazione
|
||||
import uuid # #91 id dei calcoli in attesa di nome
|
||||
from collections import OrderedDict
|
||||
from contextlib import asynccontextmanager
|
||||
@@ -96,7 +107,7 @@ from fastapi.concurrency import run_in_threadpool # #83: il grab bloccante fuor
|
||||
from fastapi.responses import HTMLResponse, Response
|
||||
|
||||
from visionsuite.camera.camera_interface import CameraError, CameraGrabError, CameraStateError
|
||||
from visionsuite.camera.sources import SourceNotFound, list_sources, open_source
|
||||
from visionsuite.camera.sources import _ESTENSIONI, SourceNotFound, list_sources, open_source # #92: _ESTENSIONI = tipi ammessi per le immagini caricate
|
||||
|
||||
# #83: calibrazione e pagina stanno accanto a questo file (che non e' un pacchetto)
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
@@ -105,6 +116,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")
|
||||
@@ -148,6 +160,9 @@ def create_app(folder: Path | str | None = None,
|
||||
archivio = calibrazione.Archivio(config_dir if config_dir is not None
|
||||
else _config_dir_default()) # #83
|
||||
aperte: dict = {}
|
||||
grezzi: dict = {} # #92: sorgente -> ultimo frame pieno GREZZO dell'anteprima (per anteprima/calibrata)
|
||||
bloccati: dict = {} # #94: sorgente -> copia del frame grezzo pieno «bloccata» per il calcolo (stesso lock di grezzi)
|
||||
cartella_immagini = archivio.cartella / "immagini" # #92: immagini caricate dalla pagina (creata al bisogno)
|
||||
lock = threading.Lock() # protegge SOLO i dizionari, mai un'apertura
|
||||
lock_sorgenti: dict = {} # nome sorgente -> Lock che serializza apertura e grab
|
||||
calcoli: OrderedDict = OrderedDict() # #91: calcolo_id -> (dati, mappa); gli ultimi _MAX_CALCOLI
|
||||
@@ -162,8 +177,32 @@ def create_app(folder: Path | str | None = None,
|
||||
with lock:
|
||||
return lock_sorgenti.setdefault(nome, threading.Lock())
|
||||
|
||||
# #92: la ricerca camere di list_sources dura ~10 s: il risultato (gia' filtrato) resta 60 s in cache
|
||||
cache_camere: dict = {"voci": None, "t": 0.0}
|
||||
lock_ricerca = threading.Lock() # due richieste insieme non lanciano due ricerche
|
||||
|
||||
def _camere(aggiorna: bool = False) -> list:
|
||||
with lock_ricerca:
|
||||
fresca = cache_camere["voci"] is not None and time.monotonic() - cache_camere["t"] < _CACHE_CAMERE_S
|
||||
if fresca and not aggiorna:
|
||||
return cache_camere["voci"]
|
||||
voci = list_sources(None, cameras=True, config=_config())
|
||||
# camera:file (replay) non e' mai configurato in TMF -> fuori dall'elenco
|
||||
cache_camere["voci"] = [{"name": v.name, "kind": v.kind, "label": v.label}
|
||||
for v in voci if v.kind in ("camera", "virtual") and v.name != "camera:file"]
|
||||
cache_camere["t"] = time.monotonic()
|
||||
return cache_camere["voci"]
|
||||
|
||||
def _prima_ricerca() -> None:
|
||||
try:
|
||||
_camere()
|
||||
except Exception: # noqa: BLE001 - e' solo un riscaldamento: la prima richiesta riprova
|
||||
_log.exception("prima ricerca camere fallita")
|
||||
|
||||
@asynccontextmanager
|
||||
async def _vita(_app):
|
||||
# #92: la prima ricerca parte all'avvio, cosi' la prima pagina non aspetta
|
||||
threading.Thread(target=_prima_ricerca, daemon=True, name="ricerca-camere").start()
|
||||
yield
|
||||
with lock: # alla chiusura rilascia le camere aperte
|
||||
sorgenti = list(aperte.values())
|
||||
@@ -177,17 +216,69 @@ def create_app(folder: Path | str | None = None,
|
||||
app = FastAPI(title="TMF camera service", lifespan=_vita)
|
||||
|
||||
@app.get("/camera/sources")
|
||||
def camera_sources():
|
||||
voci = list_sources(None, cameras=True, config=_config())
|
||||
return {"sources": [{"name": v.name, "kind": v.kind, "label": v.label}
|
||||
for v in voci if v.kind in ("camera", "virtual")]}
|
||||
def camera_sources(aggiorna: bool = False): # #92: aggiorna=true forza la ricerca (camera appena collegata)
|
||||
elenco = list(_camere(aggiorna))
|
||||
# #92: in coda le immagini caricate, sempre fresche dalla cartella (costano nulla)
|
||||
for v in list_sources(cartella_immagini, cameras=False):
|
||||
elenco.append({"name": v.name, "kind": "image", "label": f"immagine — {v.name}"})
|
||||
return {"sources": elenco}
|
||||
|
||||
def _chiudi(nome: str) -> None:
|
||||
"""#92: chiude la sorgente aperta e scorda il frame tenuto (immagine sostituita o tolta)."""
|
||||
with _lock_di(nome):
|
||||
with lock:
|
||||
s = aperte.pop(nome, None)
|
||||
grezzi.pop(nome, None)
|
||||
bloccati.pop(nome, None) # #94: tolta l'immagine/chiusa la sorgente, anche la copia bloccata
|
||||
if s is not None:
|
||||
try:
|
||||
s.close()
|
||||
except Exception: # noqa: BLE001 - la chiusura non deve far fallire la rotta
|
||||
pass
|
||||
|
||||
def _nome_immagine(grezzo: str) -> str:
|
||||
"""#92: solo il nome del file, coi caratteri sicuri di Archivio._file; "" se non ammesso."""
|
||||
base = Path((grezzo or "").replace("\\", "/")).name
|
||||
pulito = re.sub(r"[^A-Za-z0-9._-]+", "_", base).strip("._")
|
||||
radice, punto, estensione = pulito.rpartition(".")
|
||||
if not punto or not radice or f".{estensione.lower()}" not in _ESTENSIONI:
|
||||
return ""
|
||||
return f"{radice[:100]}.{estensione}"
|
||||
|
||||
@app.post("/camera/immagini", status_code=201)
|
||||
async def immagini_carica(image: UploadFile | None = File(None)):
|
||||
if image is None:
|
||||
raise HTTPException(400, "manca il file «image»")
|
||||
nome = _nome_immagine(image.filename or "")
|
||||
if not nome:
|
||||
raise HTTPException(400, "estensione non ammessa: " + ", ".join(_ESTENSIONI))
|
||||
contenuto = await image.read()
|
||||
# #92: 422 se non e' un'immagine; la verifica sta PRIMA della scrittura, cosi' non resta nulla su disco
|
||||
if not contenuto or cv2.imdecode(np.frombuffer(contenuto, np.uint8), cv2.IMREAD_UNCHANGED) is None:
|
||||
raise HTTPException(422, "il file caricato non è leggibile come immagine")
|
||||
cartella_immagini.mkdir(parents=True, exist_ok=True)
|
||||
(cartella_immagini / nome).write_bytes(contenuto) # stesso nome = sostituisce
|
||||
await run_in_threadpool(_chiudi, nome) # se era aperta, si riapre sul file nuovo
|
||||
return {"source": nome, "label": f"immagine — {nome}", "kind": "image"}
|
||||
|
||||
@app.delete("/camera/immagini", status_code=204)
|
||||
def immagini_elimina(nome: str = ""):
|
||||
percorso = cartella_immagini / nome
|
||||
# #92: solo il nome di un file dentro la cartella, niente percorsi
|
||||
if not nome or Path(nome).name != nome or not percorso.is_file():
|
||||
raise HTTPException(404, "immagine non trovata")
|
||||
_chiudi(nome)
|
||||
percorso.unlink()
|
||||
return Response(status_code=204)
|
||||
|
||||
def _apri(nome: str):
|
||||
"""#87: sorgente aperta (o da aprire); da chiamare SOLO sotto _lock_di(nome)."""
|
||||
with lock:
|
||||
sorgente = aperte.get(nome)
|
||||
if sorgente is None:
|
||||
sorgente = open_source(nome, None, config=_config())
|
||||
sorgente = open_source(nome, cartella_immagini, config=_config()) # #92: un nome di file = immagine caricata
|
||||
if hasattr(sorgente, "loop"): # #92: l'immagine singola non deve "finire" al secondo grab (anteprima continua)
|
||||
sorgente.loop = True
|
||||
# TieVision-Core#341 / #89: free-run, serve la scena di adesso, non il frame vecchio in coda
|
||||
if hasattr(sorgente, "solo_ultimo_frame"):
|
||||
try:
|
||||
@@ -284,6 +375,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:
|
||||
@@ -291,6 +383,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)
|
||||
@@ -313,7 +412,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
|
||||
|
||||
@@ -345,6 +445,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):
|
||||
@@ -414,7 +543,12 @@ def create_app(folder: Path | str | None = None,
|
||||
timeout = corpo.get("timeout_ms", 5000)
|
||||
if isinstance(timeout, bool) or not isinstance(timeout, int) or not 1 <= timeout <= 10000:
|
||||
raise HTTPException(400, "«timeout_ms» deve essere un intero fra 1 e 10000")
|
||||
frame = _grab(nome, timeout)
|
||||
def leggi(s): # #92: il frame grezzo si tiene sotto il lock della sorgente
|
||||
f = s.grab(timeout)
|
||||
with lock:
|
||||
grezzi[nome] = f.copy()
|
||||
return f
|
||||
frame = _con_sorgente(nome, leggi)
|
||||
raddrizzato = False
|
||||
if not grezzo: # come /camera/grab
|
||||
mappa = archivio.leggi_mappa(nome)
|
||||
@@ -442,6 +576,71 @@ 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. #95: senza mappa esce grezzo (X-Raddrizzato: no)."""
|
||||
corpo = corpo if isinstance(corpo, dict) else {}
|
||||
nome = _sorgente(corpo.get("source"))
|
||||
lato = corpo.get("lato_max", 1280)
|
||||
if isinstance(lato, bool) or not isinstance(lato, int) or not 320 <= lato <= 4096:
|
||||
raise HTTPException(400, "«lato_max» deve essere un intero fra 320 e 4096")
|
||||
with lock:
|
||||
frame = grezzi.get(nome)
|
||||
if frame is None:
|
||||
raise HTTPException(404, "nessun frame: avviare l'anteprima o scattare")
|
||||
attiva = archivio.attiva_di(nome)
|
||||
if attiva is None:
|
||||
raise HTTPException(409, "nessuna calibrazione attiva su questa sorgente")
|
||||
mappa = archivio.leggi_mappa(nome)
|
||||
if mappa is None: # #95: senza mappa (manuale o planare) solo scala: frame grezzo, nessuna correzione
|
||||
dati = archivio.leggi(nome) or {}
|
||||
cw, ch = dati.get("image_width"), dati.get("image_height")
|
||||
alt, larg = frame.shape[:2]
|
||||
if isinstance(cw, int) and isinstance(ch, int) and (cw, ch) != (larg, alt):
|
||||
raise HTTPException(409, f"la calibrazione «{attiva}» è per immagini {cw}×{ch}, "
|
||||
f"il frame è {larg}×{alt}")
|
||||
corretto, raddrizzato = frame, "no"
|
||||
else:
|
||||
corretto = calibrazione.raddrizza(frame, mappa)
|
||||
if corretto is None: # risoluzione diversa da quella calibrata
|
||||
ch, cw = mappa.shape[:2]
|
||||
alt, larg = frame.shape[:2]
|
||||
raise HTTPException(409, f"la calibrazione «{attiva}» è per immagini {cw}×{ch}, "
|
||||
f"il frame è {larg}×{alt}")
|
||||
raddrizzato = "si"
|
||||
alt, larg = corretto.shape[:2]
|
||||
if corretto.dtype != np.uint8: # il JPEG vuole 8 bit
|
||||
corretto = _grigio8(corretto)
|
||||
scala = lato / max(alt, larg)
|
||||
if scala < 1.0: # mai ingrandita
|
||||
corretto = cv2.resize(corretto, (max(1, round(larg * scala)), max(1, round(alt * scala))),
|
||||
interpolation=cv2.INTER_AREA)
|
||||
ok, buf = cv2.imencode(".jpg", corretto, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
||||
if not ok:
|
||||
raise HTTPException(500, "codifica JPEG fallita")
|
||||
return Response(buf.tobytes(), media_type="image/jpeg", headers={
|
||||
"X-Raddrizzato": raddrizzato, "X-Larghezza": str(larg), "X-Altezza": str(alt),
|
||||
"X-Calibrazione": urllib.parse.quote(attiva, safe="")})
|
||||
|
||||
@app.get("/camera/parametri")
|
||||
def parametri_leggi(source: str = ""):
|
||||
nome = _sorgente(source)
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Test di #94: «Blocca immagine» (scripts/camera_service.py) e riquadro vivo nella tab Calibrazione (PAGINA).
|
||||
|
||||
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
|
||||
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
|
||||
Sorgente finta che conta i grab: niente camera vera, niente ProgramData.
|
||||
"""
|
||||
import re
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from test_calibrazione import _png, cartella, client, una_scacchiera # fixture e helper della calibrazione
|
||||
from test_immagini_affiancate import _anteprima, _carica, _modulo
|
||||
from test_pagina_acquisizione import PAGINA
|
||||
|
||||
__all__ = ["cartella", "client"] # le fixture importate restano usate
|
||||
|
||||
FINTA = "finta"
|
||||
CAMPI = {"cols": "9", "rows": "6", "square_mm": "2"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def finta(client, monkeypatch):
|
||||
"""Sorgente «finta»: il primo grab da' la scacchiera, i successivi un frame bianco (non calibrabile)."""
|
||||
letture = []
|
||||
scacchiera = una_scacchiera()
|
||||
bianco = np.full(scacchiera.shape, 255, np.uint8)
|
||||
|
||||
class Finta:
|
||||
def grab(self, timeout=1000):
|
||||
letture.append(1)
|
||||
return scacchiera.copy() if len(letture) == 1 else bianco.copy()
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setitem(_modulo(client), "open_source", lambda *a, **k: Finta())
|
||||
client.letture = letture
|
||||
return client
|
||||
|
||||
|
||||
def _blocca(c, nome=FINTA):
|
||||
return c.post("/camera/blocca", json={"source": nome})
|
||||
|
||||
|
||||
def _calcola(c, **extra):
|
||||
return c.post("/camera/calibrate", data={"source": FINTA, **CAMPI, **extra})
|
||||
|
||||
|
||||
# ---- servizio
|
||||
|
||||
def test_blocca_senza_frame_404(finta):
|
||||
r = _blocca(finta)
|
||||
assert r.status_code == 404 and r.json()["detail"] == "nessun frame: avviare l'anteprima o scattare"
|
||||
|
||||
|
||||
def test_blocca_senza_source_400(finta):
|
||||
assert finta.post("/camera/blocca", json={}).status_code == 400
|
||||
assert finta.post("/camera/blocca").status_code == 400
|
||||
|
||||
|
||||
def test_anteprima_poi_blocca_200_con_misure(finta):
|
||||
assert _anteprima(finta, FINTA).status_code == 200
|
||||
r = _blocca(finta)
|
||||
alt, larg = una_scacchiera().shape[:2]
|
||||
assert r.status_code == 200 and r.json() == {"source": FINTA, "larghezza": larg, "altezza": alt}
|
||||
|
||||
|
||||
def test_calibrate_bloccata_usa_la_copia_senza_grab(finta):
|
||||
assert _anteprima(finta, FINTA).status_code == 200 # frame 1: la scacchiera
|
||||
assert _blocca(finta).status_code == 200
|
||||
assert _anteprima(finta, FINTA).status_code == 200 # frame 2: bianco, il grezzo cambia ma la copia no
|
||||
prima = len(finta.letture)
|
||||
r = _calcola(finta, bloccata="true")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["bloccata"] is True and r.json()["origine"] == FINTA
|
||||
assert len(finta.letture) == prima # nessun grab per il calcolo
|
||||
# senza il blocco il calcolo legge un frame nuovo (bianco): non calibrabile
|
||||
assert _calcola(finta).status_code == 422
|
||||
assert len(finta.letture) == prima + 1
|
||||
|
||||
|
||||
def test_calibrate_normale_dice_bloccata_false(client):
|
||||
r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["bloccata"] is False
|
||||
|
||||
|
||||
def test_calibrate_bloccata_senza_copia_409(finta):
|
||||
assert _anteprima(finta, FINTA).status_code == 200 # il grezzo c'e', ma non e' bloccato
|
||||
r = _calcola(finta, bloccata="true")
|
||||
assert r.status_code == 409 and r.json()["detail"] == "nessuna immagine bloccata su questa sorgente"
|
||||
|
||||
|
||||
def test_sblocca_204_idempotente_e_toglie_la_copia(finta):
|
||||
assert finta.delete("/camera/blocca", params={"source": FINTA}).status_code == 204 # niente da togliere
|
||||
assert _anteprima(finta, FINTA).status_code == 200
|
||||
assert _blocca(finta).status_code == 200
|
||||
assert finta.delete("/camera/blocca", params={"source": FINTA}).status_code == 204
|
||||
assert _calcola(finta, bloccata="true").status_code == 409
|
||||
assert finta.delete("/camera/blocca", params={"source": FINTA}).status_code == 204
|
||||
|
||||
|
||||
def test_rimuovere_l_immagine_toglie_la_copia_bloccata(client):
|
||||
assert _carica(client, "foto.png", una_scacchiera()).status_code == 201
|
||||
assert _anteprima(client, "foto.png").status_code == 200
|
||||
assert _blocca(client, "foto.png").status_code == 200
|
||||
assert client.delete("/camera/immagini", params={"nome": "foto.png"}).status_code == 204
|
||||
assert _carica(client, "foto.png", una_scacchiera()).status_code == 201
|
||||
r = client.post("/camera/calibrate", data={"source": "foto.png", "bloccata": "true", **CAMPI})
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
# ---- pagina (contenuto statico)
|
||||
|
||||
def test_pagina_riquadro_e_casella_nella_tab_calibrazione():
|
||||
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
|
||||
assert {"immagineCalTab", "vuotoCalTab", "scattaCal", "bloccaImg", "msgBlocco"} <= ids
|
||||
assert "Nessuna immagine: «Scatta» o avviare l'anteprima nella tab Acquisizione" in PAGINA
|
||||
assert "Blocca immagine" in PAGINA
|
||||
tab = PAGINA.split('id="pannelloCal"')[1].split('id="sezioneSalvate"')[0]
|
||||
assert "immagineCalTab" in tab and "bloccaImg" in tab # nella sezione Calibrazione, non altrove
|
||||
|
||||
|
||||
def test_pagina_scegli_tab_non_ferma_piu_il_ciclo():
|
||||
assert "A.riprendi" not in PAGINA
|
||||
scegli = PAGINA.split("function scegliTab")[1].split("$(\"tabAcq\").onclick")[0]
|
||||
assert "fermaAnteprima" not in scegli and "avviaAnteprima" not in scegli
|
||||
|
||||
|
||||
def test_pagina_calcola_manda_bloccata_e_non_ferma_il_ciclo():
|
||||
calcola = PAGINA.split("async function calcola")[1].split("async function salvaNome")[0]
|
||||
assert 'f.append("bloccata", "true")' in calcola
|
||||
assert "if (bloccata)" in calcola and "else fermaAnteprima();" in calcola
|
||||
assert "immagine bloccata" in calcola
|
||||
assert "l'anteprima resta sospesa" not in PAGINA and "si può bloccare l'immagine" in PAGINA
|
||||
|
||||
|
||||
def test_pagina_blocco_rotte_e_cambio_sorgente():
|
||||
assert '"/camera/blocca"' in PAGINA and '"/camera/blocca?source="' in PAGINA
|
||||
assert "method: \"DELETE\"" in PAGINA
|
||||
cambio = PAGINA.split("function cambioSorgente")[1].split("// ---- Anteprima")[0]
|
||||
assert "rilasciaBlocco()" in cambio
|
||||
assert cambio.index("rilasciaBlocco()") < cambio.index("svuotaRiquadri()")
|
||||
|
||||
|
||||
def test_pagina_blocco_attende_l_anteprima_in_volo():
|
||||
# #94: il frame bloccato e' quello mostrato: flag A.bloccando, attesa di A.inVolo (20 ms, max 3 s), poi POST, flag tolto in finally
|
||||
blocco = PAGINA.split('$("bloccaImg").onchange')[1].split('$("avvia").onclick')[0]
|
||||
assert "A.bloccando++" in blocco and "A.bloccando--" in blocco and "finally" in blocco
|
||||
assert "k < 150" in blocco and "pausa(20)" in blocco
|
||||
assert blocco.index("A.inVolo") < blocco.index('fetch("/camera/blocca"')
|
||||
ciclo = PAGINA.split("async function ciclo")[1].split("function avviaAnteprima")[0]
|
||||
assert "while (A.bloccando)" in ciclo and ciclo.index("A.bloccando") < ciclo.index("unaAnteprima(1280)")
|
||||
scatta = PAGINA.split("async function scatta")[1].split("// #94: «Blocca immagine»")[0]
|
||||
assert "while (A.bloccando)" in scatta and scatta.index("A.bloccando") < scatta.index("unaAnteprima(4096)")
|
||||
@@ -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,292 @@
|
||||
"""Test di #92: immagini caricate come sorgenti e anteprima calibrata (scripts/camera_service.py).
|
||||
|
||||
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
|
||||
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
|
||||
Solo tmp_path: niente ProgramData, niente camera vera. Le immagini le scrive il test.
|
||||
"""
|
||||
from urllib.parse import quote, unquote
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from test_calibrazione import ( # fixture e helper della calibrazione (#83/#84)
|
||||
CAMPI, _png, carica_su, cartella, client, salva_con_nome, una_scacchiera,
|
||||
una_scacchiera_densa,
|
||||
)
|
||||
|
||||
__all__ = ["cartella", "client"] # le fixture importate restano usate
|
||||
|
||||
DENSA = "densa.png"
|
||||
PIANA = "piana.png"
|
||||
|
||||
|
||||
def _modulo(c) -> dict:
|
||||
"""Il dict globale di camera_service (per sostituire open_source con una spia)."""
|
||||
return next(r.endpoint for r in c.app.routes if r.path == "/camera/grab").__globals__
|
||||
|
||||
|
||||
def _carica(c, nome: str, img: np.ndarray, **extra):
|
||||
"""POST /camera/immagini con un'immagine codificata PNG (o il contenuto dato)."""
|
||||
return c.post("/camera/immagini", files={"image": (nome, _png(img), "image/png")}, **extra)
|
||||
|
||||
|
||||
def _densa(**kw) -> np.ndarray:
|
||||
return una_scacchiera_densa(k=1e-7, **kw)
|
||||
|
||||
|
||||
def _calibra_e_attiva(c, nome_immagine: str, nome_calibrazione: str, **campi):
|
||||
"""Calcola dalla sorgente, salva con nome e la rende attiva su quella sorgente."""
|
||||
r = c.post("/camera/calibrate", data={"source": nome_immagine, "square_mm": "2", **campi})
|
||||
assert r.status_code == 200, r.text
|
||||
assert salva_con_nome(c, r.json(), nome_calibrazione).status_code == 201
|
||||
assert carica_su(c, nome_immagine, nome_calibrazione).status_code == 200
|
||||
|
||||
|
||||
def _anteprima(c, nome: str, **extra):
|
||||
return c.post("/camera/anteprima", json={"source": nome, "grezzo": True, **extra})
|
||||
|
||||
|
||||
def _calibrata(c, nome: str, **extra):
|
||||
return c.post("/camera/anteprima/calibrata", json={"source": nome, **extra})
|
||||
|
||||
|
||||
# ---- POST /camera/immagini
|
||||
|
||||
def test_carica_201_salva_in_cartella_immagini(client):
|
||||
r = _carica(client, "calibration_135_1.bmp", una_scacchiera())
|
||||
assert r.status_code == 201, r.text
|
||||
assert r.json() == {"source": "calibration_135_1.bmp",
|
||||
"label": "immagine — calibration_135_1.bmp", "kind": "image"}
|
||||
assert (client.config / "immagini" / "calibration_135_1.bmp").is_file()
|
||||
|
||||
|
||||
def test_carica_ripulisce_il_nome_e_toglie_il_percorso(client):
|
||||
r = _carica(client, "C:\\foto\\ban co è1.PNG", una_scacchiera())
|
||||
assert r.status_code == 201, r.text
|
||||
assert r.json()["source"] == "ban_co_1.PNG" # niente percorso, caratteri sicuri, estensione intatta
|
||||
assert (client.config / "immagini" / "ban_co_1.PNG").is_file()
|
||||
|
||||
|
||||
def test_carica_stesso_nome_sostituisce(client):
|
||||
_carica(client, "a.png", np.full((48, 64), 10, np.uint8))
|
||||
assert _carica(client, "a.png", np.full((30, 40), 200, np.uint8)).status_code == 201
|
||||
assert [f.name for f in (client.config / "immagini").iterdir()] == ["a.png"]
|
||||
img = cv2.imread(str(client.config / "immagini" / "a.png"), cv2.IMREAD_UNCHANGED)
|
||||
assert img.shape[:2] == (30, 40)
|
||||
|
||||
|
||||
def test_carica_senza_file_400(client):
|
||||
assert client.post("/camera/immagini").status_code == 400
|
||||
|
||||
|
||||
def test_carica_estensione_non_ammessa_400(client):
|
||||
r = client.post("/camera/immagini", files={"image": ("note.txt", b"ciao", "text/plain")})
|
||||
assert r.status_code == 400
|
||||
assert not (client.config / "immagini").exists() or not list((client.config / "immagini").iterdir())
|
||||
|
||||
|
||||
def test_carica_file_illeggibile_422_e_niente_su_disco(client):
|
||||
r = client.post("/camera/immagini", files={"image": ("rotta.png", b"non e una immagine", "image/png")})
|
||||
assert r.status_code == 422
|
||||
assert not (client.config / "immagini").exists() or not list((client.config / "immagini").iterdir())
|
||||
|
||||
|
||||
# ---- GET /camera/sources
|
||||
|
||||
def test_sources_con_immagini_e_senza_camera_file(client):
|
||||
_carica(client, "b.png", una_scacchiera())
|
||||
_carica(client, "a.png", una_scacchiera())
|
||||
voci = client.get("/camera/sources").json()["sources"]
|
||||
nomi = [v["name"] for v in voci]
|
||||
assert "camera:file" not in nomi
|
||||
assert "camera:folder" in nomi
|
||||
assert nomi[-2:] == ["a.png", "b.png"] # le immagini stanno in coda
|
||||
assert voci[-1] == {"name": "b.png", "kind": "image", "label": "immagine — b.png"}
|
||||
assert nomi.index("camera:folder") < nomi.index("a.png")
|
||||
|
||||
|
||||
def test_sources_camere_in_cache_e_aggiorna(client):
|
||||
# #92: la ricerca camere (~10 s) si fa una volta e resta 60 s; aggiorna=true la rilancia; le immagini sono sempre fresche
|
||||
g = _modulo(client)
|
||||
originale = g["list_sources"]
|
||||
chiamate = []
|
||||
|
||||
def finto(cartella, cameras=True, **kw):
|
||||
if cameras:
|
||||
chiamate.append(1)
|
||||
return originale(cartella, cameras=cameras, **kw)
|
||||
|
||||
g["list_sources"] = finto
|
||||
try:
|
||||
client.get("/camera/sources") # se la ricerca d'avvio e' ancora in corso aspetta il suo lock
|
||||
base = len(chiamate)
|
||||
_carica(client, "nuova.png", una_scacchiera())
|
||||
voci = client.get("/camera/sources").json()["sources"]
|
||||
client.get("/camera/sources")
|
||||
assert len(chiamate) == base # seconda e terza chiamata: nessuna nuova ricerca
|
||||
assert voci[-1]["name"] == "nuova.png" # ma l'immagine appena caricata c'e'
|
||||
client.get("/camera/sources", params={"aggiorna": "true"})
|
||||
assert len(chiamate) == base + 1 # aggiorna=true la rilancia
|
||||
client.get("/camera/sources")
|
||||
assert len(chiamate) == base + 1 # e il risultato nuovo torna in cache
|
||||
finally:
|
||||
g["list_sources"] = originale
|
||||
|
||||
|
||||
def test_sources_senza_immagini_non_crea_la_cartella(client):
|
||||
voci = client.get("/camera/sources").json()["sources"]
|
||||
assert all(v["kind"] != "image" for v in voci)
|
||||
|
||||
|
||||
# ---- grab / anteprima / calibrate su un'immagine caricata
|
||||
|
||||
def test_grab_anteprima_e_grab_ripetuti_su_immagine(client):
|
||||
_carica(client, "foto.png", una_scacchiera())
|
||||
for _ in range(3): # l'anteprima continua non deve esaurire un'immagine sola
|
||||
r = client.post("/camera/grab", json={"source": "foto.png"})
|
||||
assert r.status_code == 200 and r.headers["content-type"] == "image/png"
|
||||
for _ in range(3):
|
||||
r = _anteprima(client, "foto.png")
|
||||
assert r.status_code == 200 and r.headers["content-type"] == "image/jpeg"
|
||||
assert (r.headers["X-Larghezza"], r.headers["X-Altezza"]) == ("520", "400")
|
||||
|
||||
|
||||
def test_grab_immagine_inesistente_404(client):
|
||||
assert client.post("/camera/grab", json={"source": "non_c_e.png"}).status_code == 404
|
||||
|
||||
|
||||
def test_calibrate_su_immagine_origine_e_il_nome(client):
|
||||
_carica(client, "calibration_135_1.bmp", una_scacchiera())
|
||||
r = client.post("/camera/calibrate", data={"source": "calibration_135_1.bmp", **CAMPI})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["origine"] == "calibration_135_1.bmp"
|
||||
|
||||
|
||||
# ---- DELETE /camera/immagini
|
||||
|
||||
def test_elimina_204_poi_404_e_sparisce_dalle_sorgenti(client):
|
||||
_carica(client, "foto.png", una_scacchiera())
|
||||
assert client.delete("/camera/immagini", params={"nome": "foto.png"}).status_code == 204
|
||||
assert not (client.config / "immagini" / "foto.png").exists()
|
||||
assert client.delete("/camera/immagini", params={"nome": "foto.png"}).status_code == 404
|
||||
assert "foto.png" not in [v["name"] for v in client.get("/camera/sources").json()["sources"]]
|
||||
assert client.post("/camera/grab", json={"source": "foto.png"}).status_code == 404
|
||||
|
||||
|
||||
def test_elimina_nome_con_percorso_404(client):
|
||||
_carica(client, "foto.png", una_scacchiera())
|
||||
assert client.delete("/camera/immagini", params={"nome": "../immagini/foto.png"}).status_code == 404
|
||||
assert client.delete("/camera/immagini").status_code == 404
|
||||
assert (client.config / "immagini" / "foto.png").is_file()
|
||||
|
||||
|
||||
def test_elimina_chiude_la_sorgente_aperta(client, monkeypatch):
|
||||
chiuse = []
|
||||
modulo = _modulo(client)
|
||||
reale = modulo["open_source"]
|
||||
|
||||
def spia(*a, **k):
|
||||
s = reale(*a, **k)
|
||||
chiusura = s.close
|
||||
s.close = lambda: (chiuse.append(a[0]), chiusura())[1]
|
||||
return s
|
||||
|
||||
monkeypatch.setitem(modulo, "open_source", spia)
|
||||
_carica(client, "foto.png", una_scacchiera())
|
||||
assert client.post("/camera/grab", json={"source": "foto.png"}).status_code == 200
|
||||
assert client.delete("/camera/immagini", params={"nome": "foto.png"}).status_code == 204
|
||||
assert chiuse == ["foto.png"]
|
||||
|
||||
|
||||
# ---- POST /camera/anteprima/calibrata
|
||||
|
||||
def test_calibrata_senza_frame_404(client):
|
||||
_carica(client, DENSA, _densa())
|
||||
r = _calibrata(client, DENSA)
|
||||
assert r.status_code == 404 and r.json()["detail"] == "nessun frame: avviare l'anteprima o scattare"
|
||||
|
||||
|
||||
def test_calibrata_senza_attiva_409(client):
|
||||
_carica(client, DENSA, _densa())
|
||||
assert _anteprima(client, DENSA).status_code == 200
|
||||
r = _calibrata(client, DENSA)
|
||||
assert r.status_code == 409 and r.json()["detail"] == "nessuna calibrazione attiva su questa sorgente"
|
||||
|
||||
|
||||
def test_calibrata_validazione_ingresso(client):
|
||||
assert client.post("/camera/anteprima/calibrata", json={}).status_code == 400
|
||||
assert _calibrata(client, DENSA, lato_max=100).status_code == 400
|
||||
assert _calibrata(client, DENSA, lato_max=True).status_code == 400
|
||||
|
||||
|
||||
def test_calibrata_200_densa_senza_nuova_lettura(client, monkeypatch):
|
||||
modulo = _modulo(client)
|
||||
reale = modulo["open_source"]
|
||||
letture = []
|
||||
|
||||
def conta(*a, **k):
|
||||
s = reale(*a, **k)
|
||||
originale = s.grab
|
||||
s.grab = lambda t=1000: (letture.append(1), originale(t))[1]
|
||||
return s
|
||||
|
||||
monkeypatch.setitem(modulo, "open_source", conta)
|
||||
_carica(client, DENSA, _densa())
|
||||
_calibra_e_attiva(client, DENSA, "Banco àè 1")
|
||||
assert _anteprima(client, DENSA).status_code == 200
|
||||
prima = len(letture)
|
||||
for _ in range(3):
|
||||
r = _calibrata(client, DENSA)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.headers["content-type"] == "image/jpeg"
|
||||
assert r.headers["X-Raddrizzato"] == "si"
|
||||
assert (r.headers["X-Larghezza"], r.headers["X-Altezza"]) == ("640", "480")
|
||||
assert unquote(r.headers["X-Calibrazione"]) == "Banco àè 1"
|
||||
assert r.headers["X-Calibrazione"] == quote("Banco àè 1", safe="") # percent-encoded UTF-8
|
||||
img = cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_UNCHANGED)
|
||||
assert img.shape[:2] == (480, 640)
|
||||
assert len(letture) == prima # la calibrata non legge mai un frame nuovo
|
||||
|
||||
|
||||
def test_calibrata_ridotta_a_lato_max(client):
|
||||
_carica(client, DENSA, _densa())
|
||||
_calibra_e_attiva(client, DENSA, "Banco")
|
||||
assert _anteprima(client, DENSA).status_code == 200
|
||||
r = _calibrata(client, DENSA, lato_max=320)
|
||||
assert r.status_code == 200
|
||||
img = cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_UNCHANGED)
|
||||
assert max(img.shape[:2]) == 320
|
||||
assert (r.headers["X-Larghezza"], r.headers["X-Altezza"]) == ("640", "480") # misure del frame pieno
|
||||
|
||||
|
||||
def test_anteprima_grezza_e_calibrata_usano_lo_stesso_frame_grezzo(client):
|
||||
# anche con anteprima NON grezza la copia tenuta e' quella prima di raddrizzare
|
||||
_carica(client, DENSA, _densa())
|
||||
_calibra_e_attiva(client, DENSA, "Banco")
|
||||
r = client.post("/camera/anteprima", json={"source": DENSA}) # calibrata: raddrizzata
|
||||
assert r.status_code == 200 and r.headers["X-Raddrizzato"] == "si"
|
||||
r = _calibrata(client, DENSA)
|
||||
assert r.status_code == 200 and r.headers["X-Raddrizzato"] == "si" # raddrizzata una volta sola
|
||||
|
||||
|
||||
def test_calibrata_risoluzione_diversa_409(client):
|
||||
_carica(client, DENSA, _densa())
|
||||
_calibra_e_attiva(client, DENSA, "Banco")
|
||||
# stessa sorgente, immagine sostituita con un'altra risoluzione: la calibrazione resta attiva
|
||||
assert _carica(client, DENSA, _densa(w=320, h=240)).status_code == 201
|
||||
assert _calibrata(client, DENSA).status_code == 404 # sostituire l'immagine scorda il frame vecchio
|
||||
assert _anteprima(client, DENSA).status_code == 200
|
||||
r = _calibrata(client, DENSA)
|
||||
assert r.status_code == 409
|
||||
assert r.json()["detail"] == "la calibrazione «Banco» è per immagini 640×480, il frame è 320×240"
|
||||
|
||||
|
||||
def test_calibrata_planare_senza_mappa_200_grezza(client):
|
||||
# #95: senza mappa (planare) non e' piu' 409: esce il frame grezzo, X-Raddrizzato: no
|
||||
_carica(client, PIANA, una_scacchiera())
|
||||
_calibra_e_attiva(client, PIANA, "Piana", cols="9", rows="6")
|
||||
assert _anteprima(client, PIANA).status_code == 200
|
||||
r = _calibrata(client, PIANA)
|
||||
assert r.status_code == 200
|
||||
assert r.headers["X-Raddrizzato"] == "no" and unquote(r.headers["X-Calibrazione"]) == "Piana"
|
||||
@@ -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",
|
||||
"carica", "salva", "elimina", "stato", "statoSorgente"}
|
||||
"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
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Test della pagina a due tab (#91, #92): contenuto statico di PAGINA, senza servizio."""
|
||||
|
||||
import importlib.util
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
_FILE = Path(__file__).resolve().parents[1] / "calibrazione_pagina.py"
|
||||
_spec = importlib.util.spec_from_file_location("calibrazione_pagina_tab", _FILE)
|
||||
_mod = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_mod)
|
||||
PAGINA = _mod.PAGINA
|
||||
|
||||
|
||||
def test_due_tab_con_ruolo_e_selezione():
|
||||
assert re.search(r'<button id="tabAcq"[^>]*role="tab"[^>]*aria-selected="true"', PAGINA)
|
||||
assert re.search(r'<button id="tabCal"[^>]*role="tab"[^>]*aria-selected="false"', PAGINA)
|
||||
assert ">Acquisizione</button>" in PAGINA and ">Calibrazione</button>" in PAGINA
|
||||
assert 'role="tablist"' in PAGINA and 'role="tabpanel"' in PAGINA
|
||||
assert 'aria-selected", cal ?' in PAGINA # la selezione cambia da JS
|
||||
assert '"tmf.acq.tab"' in PAGINA # tab scelta ricordata (via memoria(), protetta)
|
||||
|
||||
|
||||
def test_sorgente_in_cima_poi_riquadri():
|
||||
# #92: Sorgente, anteprima e parametri stanno nel pannello Acquisizione; Sorgente prima dei riquadri
|
||||
i_acq = PAGINA.index('id="pannelloAcq"')
|
||||
i_cal = PAGINA.index('id="pannelloCal"')
|
||||
i_sorg = PAGINA.index('id="sezioneSorgente"')
|
||||
i_ant = PAGINA.index('id="sezioneAnteprima"')
|
||||
i_riq = PAGINA.index('id="riquadri"')
|
||||
assert i_acq < i_sorg < i_ant < i_riq < PAGINA.index('id="sezioneParametri"') < i_cal
|
||||
assert PAGINA.index('id="sezioneCalibrazione"') > i_cal
|
||||
# barra: casella calibrata e mirino; la vecchia casella «Immagine grezza» non c'e' piu'
|
||||
assert 'id="mostraCal"' in PAGINA and "Attiva calibrazione" in PAGINA
|
||||
assert 'id="grezzo"' not in PAGINA and "Immagine grezza (senza correzione)" not in PAGINA
|
||||
# riquadri: «Immagine» sempre visibile, «Calibrata» nascosta finche' la casella e' spenta
|
||||
assert re.search(r'<div class="riquadro" id="riquadroCal" hidden>', PAGINA)
|
||||
assert "grezzo: true" in PAGINA
|
||||
|
||||
|
||||
def test_id_del_pannello_calibrazione():
|
||||
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
|
||||
attesi = {"calSorgente", "quadrato", "acquisisci", "nomeCal", "salva", "msgSalva", "calibrazioni",
|
||||
"msgElenco", "stato", "risultato", "overlay",
|
||||
"caricaImmagine", "fileImmagine", "rimuoviImmagine", "riquadroCal", "immagineCal", "mirinoCal"}
|
||||
assert attesi <= ids, attesi - ids
|
||||
usati = set(re.findall(r'\$\("([^"]+)"\)', PAGINA))
|
||||
assert usati <= ids, usati - ids
|
||||
assert len(re.findall(r'id="([^"]+)"', PAGINA)) == len(ids) # nessun id doppio
|
||||
|
||||
|
||||
def test_calibrazione_senza_file_ne_radio():
|
||||
# #92: l'immagine da file e' una sorgente di acquisizione, non un'opzione della calibrazione
|
||||
for sparito in ("origFile", "origSorgente", "scegliFile", "nomeFile", 'type="radio"', "Immagine da file", "Sorgente selezionata"):
|
||||
assert sparito not in PAGINA, sparito
|
||||
# l'unico input file e' nella sezione Sorgente, in cima alla tab Acquisizione
|
||||
assert PAGINA.count('type="file"') == 1
|
||||
assert PAGINA.index('type="file"') < PAGINA.index('id="pannelloCal"')
|
||||
assert "immagine grezza dalla tab Acquisizione" in PAGINA
|
||||
# il calcolo manda solo la sorgente (e il lato), mai il file
|
||||
assert 'f.append("source", sorgente)' in PAGINA and 'f.append("image"' not in PAGINA.split("async function calcola")[1].split("async function salvaNome")[0]
|
||||
|
||||
|
||||
def test_testi_del_pannello():
|
||||
for testo in ("Camera o immagine", "Carica immagine da file...", "Rimuovi immagine", "Calcola",
|
||||
"Calibrazioni salvate", "risoluzione diversa", "Calibrata",
|
||||
"Carica", "Elimina", "Scollega", "Sovrascrivi «", "Clic ancora", "Nome",
|
||||
"calibrata: ", "non calibrata"):
|
||||
assert testo in PAGINA, testo
|
||||
|
||||
|
||||
def test_rotte_del_contratto():
|
||||
for rotta in ("/camera/calibrazioni", "/camera/calibrazione-attiva", "/camera/calibrate",
|
||||
"/camera/calibration?source=", "/camera/immagini", "/camera/immagini?nome=",
|
||||
"/camera/anteprima/calibrata"): # #92
|
||||
assert '"' + rotta in PAGINA, rotta
|
||||
for parola in ("calcolo_id", "sovrascrivi", "method: \"PUT\"", "method: \"DELETE\"", "X-Larghezza", "X-Altezza"):
|
||||
assert parola in PAGINA, parola
|
||||
# il calcolo non salva piu': il campo save sparisce
|
||||
assert 'append("save"' not in PAGINA
|
||||
|
||||
|
||||
def test_nessuna_finestra_del_browser_e_offline():
|
||||
for vietato in ("alert(", "confirm(", "prompt(", "http://", "https://"):
|
||||
assert vietato not in PAGINA, vietato
|
||||
|
||||
|
||||
def test_acquisizione_di_87_90_conservata():
|
||||
# anteprima, indicatori, mirino, grezzo, parametri camera e ciclo a 100 ms restano
|
||||
for parola in ("Parametri camera", "Mirino centrale",
|
||||
"Math.max(100 - (Date.now() - t0), 0)", "/camera/anteprima", "/camera/parametri"):
|
||||
assert parola in PAGINA, parola
|
||||
|
||||
|
||||
def test_pagina_stretta():
|
||||
assert "@media (max-width: 500px)" in PAGINA
|
||||
# #91: elenco a schede, niente tabella larga ne' scorrimento orizzontale
|
||||
assert 'id="calibrazioni" class="elenco"' in PAGINA and "overflow-x" not in PAGINA
|
||||
assert "Lettura dei parametri..." in PAGINA
|
||||
|
||||
|
||||
def test_ciclo_non_sospeso_nella_tab_calibrazione():
|
||||
# #94: il ciclo non si ferma piu' entrando in Calibrazione (prima #92: A.riprendi); Calcola senza blocco aspetta le richieste in volo
|
||||
assert "A.riprendi" not in PAGINA
|
||||
calcola = PAGINA.split("async function calcola")[1].split("async function salvaNome")[0]
|
||||
assert "fermaAnteprima();" in calcola and "A.inVolo) await pausa(50)" in calcola
|
||||
|
||||
|
||||
def test_calibrata_dopo_anteprima_e_dopo_carica():
|
||||
# #92: la calibrata segue ogni anteprima grezza riuscita e si aggiorna dopo Carica/Scollega; 404/409 al posto dell'immagine
|
||||
assert "await aggiornaCalibrata(lato);" in PAGINA
|
||||
assert PAGINA.count("aggiornaCalibrata();") >= 3
|
||||
assert "messaggioCalibrata(await dettaglio(r))" in PAGINA
|
||||
assert 'decodeURIComponent(r.headers.get("X-Calibrazione")' in PAGINA
|
||||
|
||||
|
||||
def test_elenco_sorgenti_senza_attese_e_calibrata_solo_se_serve():
|
||||
# #92: la voce dell'immagine caricata si aggiunge in pagina (niente rilettura dell'elenco); «Aggiorna» forza la ricerca camere
|
||||
assert "aggiungiSorgente(j);" in PAGINA and "caricaSorgenti(j.source)" not in PAGINA
|
||||
assert 'id="aggiornaSorgenti"' in PAGINA and ">Aggiorna</button>" in PAGINA
|
||||
assert '"?aggiorna=true"' in PAGINA
|
||||
# nessuna calibrazione attiva: il riquadro lo dice senza chiamare il servizio; dopo un 409 non si richiede piu'
|
||||
assert "if (A.calAttiva === false)" in PAGINA
|
||||
assert "nessuna calibrazione attiva su questa sorgente" in PAGINA
|
||||
assert "if (r.status === 409) A.bloccoCal = true;" in PAGINA and "if (A.bloccoCal) return;" in PAGINA
|
||||
assert PAGINA.count("A.bloccoCal = false;") >= 3 # cambio sorgente, Carica, Scollega
|
||||
|
||||
|
||||
def test_attiva_calibrazione_con_scelta_e_conferma():
|
||||
# #93: sotto la calibrata una combo e CONFERMA; la casella cambia nome
|
||||
for i in ('id="sceltaCal"', 'id="confermaCal"', 'id="msgSceltaCal"'):
|
||||
assert i in PAGINA, i
|
||||
assert "Attiva calibrazione" in PAGINA and ">CONFERMA</button>" in PAGINA
|
||||
assert "Mostra immagine calibrata" not in PAGINA
|
||||
assert PAGINA.index('id="riquadroCal"') < PAGINA.index('id="sceltaCal"') < PAGINA.index('id="confermaCal"') < PAGINA.index('id="msgSceltaCal"')
|
||||
# il gestore di CONFERMA usa PUT calibrazione-attiva (via caricaNome) o DELETE calibration (via scollega), messaggio sotto la calibrata
|
||||
gestore = PAGINA[PAGINA.index('$("confermaCal").onclick'):]
|
||||
gestore = gestore[:gestore.index("};")]
|
||||
assert 'caricaNome(nome, "msgSceltaCal")' in gestore and 'scollega("msgSceltaCal")' in gestore
|
||||
assert 'method: "PUT"' in PAGINA and "/camera/calibrazione-attiva" in PAGINA
|
||||
assert 'method: "DELETE"' in PAGINA and '"/camera/calibration?source="' in PAGINA
|
||||
# voce vuota «nessuna», suffisso di risoluzione, combo ridisegnata con l'elenco e spenta senza sorgente
|
||||
assert "— nessuna (non calibrata) —" in PAGINA and "— risoluzione diversa" in PAGINA
|
||||
assert "disegnaSceltaCal();" in PAGINA and "= !$(\"sorgente\").value" in PAGINA
|
||||
@@ -541,6 +541,55 @@ def api_list_stations_for_maker():
|
||||
return jsonify(out), 200
|
||||
|
||||
|
||||
def _camera_services() -> list[dict]:
|
||||
"""#97: servizi di acquisizione distinti delle stazioni che hanno una camera_url."""
|
||||
services: dict[str, list[str]] = {}
|
||||
for st in _stations_for_maker():
|
||||
url = (st.get("camera_url") or "").strip().rstrip("/")
|
||||
if not url:
|
||||
continue
|
||||
label = f"{st.get('code') or ''} - {st.get('name') or ''}"
|
||||
services.setdefault(url, []).append(label)
|
||||
return [{"camera_url": u, "stazioni": s} for u, s in services.items()]
|
||||
|
||||
|
||||
@maker_bp.route("/api/camera/servizi", methods=["GET"])
|
||||
@login_required
|
||||
@maker_or_admin_required
|
||||
def api_camera_services():
|
||||
"""#97: servizi di acquisizione fra cui scegliere la camera di un task."""
|
||||
return jsonify({"servizi": _camera_services()}), 200
|
||||
|
||||
|
||||
@maker_bp.route("/api/camera/sorgenti", methods=["GET"])
|
||||
@login_required
|
||||
@maker_or_admin_required
|
||||
def api_camera_sources():
|
||||
"""#97: proxy di GET {url}/camera/sources; solo per i servizi delle stazioni."""
|
||||
url = (request.args.get("url") or "").strip().rstrip("/")
|
||||
# Niente richieste verso indirizzi arbitrari: url ammesso solo se e' un servizio noto
|
||||
if not url or url not in {s["camera_url"] for s in _camera_services()}:
|
||||
return jsonify({
|
||||
"error": True,
|
||||
"detail": _("Servizio di acquisizione non ammesso"),
|
||||
}), 400
|
||||
try:
|
||||
resp = http_requests.get(f"{url}/camera/sources", timeout=15)
|
||||
body = resp.json() if resp.status_code == 200 else None
|
||||
except (http_requests.RequestException, ValueError) as exc:
|
||||
return jsonify({
|
||||
"error": True,
|
||||
"detail": _("Servizio camera non raggiungibile: %(detail)s", detail=str(exc)),
|
||||
}), 502
|
||||
sources = body.get("sources") if isinstance(body, dict) else None
|
||||
if not isinstance(sources, list):
|
||||
return jsonify({
|
||||
"error": True,
|
||||
"detail": _("Risposta del servizio camera non valida (%(status)s)", status=resp.status_code),
|
||||
}), 502
|
||||
return jsonify({"sources": sources}), 200
|
||||
|
||||
|
||||
# Copia minima della logica di measure.api_vision_grab, ma per una stazione scelta per id
|
||||
@maker_bp.route("/api/stations/<int:station_id>/camera/grab", methods=["GET", "POST"])
|
||||
@login_required
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -547,6 +547,71 @@
|
||||
|
||||
<div class="tmf-card-body space-y-4">
|
||||
|
||||
<!-- ========== Acquisizione (#97): camera e modo di analisi, solo camera_measure / dxf_compare ========== -->
|
||||
<template x-if="task.task_type === 'camera_measure' || task.task_type === 'dxf_compare'">
|
||||
<div class="p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]">
|
||||
<p class="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wide mb-2">
|
||||
{{ _('Acquisizione') }}
|
||||
</p>
|
||||
<!-- Lettura: camera e modo salvati nel task -->
|
||||
<template x-if="editingTask !== task.id">
|
||||
<div class="text-xs space-y-1">
|
||||
<template x-if="task.acquisition_json && task.acquisition_json.camera_url">
|
||||
<div class="space-y-1">
|
||||
<p><span class="text-[var(--text-secondary)]">{{ _('Servizio di acquisizione') }}:</span>
|
||||
<span class="font-mono" x-text="task.acquisition_json.camera_url"></span></p>
|
||||
<p><span class="text-[var(--text-secondary)]">{{ _('Sorgente') }}:</span>
|
||||
<span class="font-mono" x-text="task.acquisition_json.camera_source"></span></p>
|
||||
<p><span class="text-[var(--text-secondary)]">{{ _('Modo di analisi') }}:</span>
|
||||
<span x-text="task.acquisition_json.modo === 'in_linea' ? {{ _('Analisi in linea')|tojson_attr }} : {{ _('Analisi impulsiva')|tojson_attr }}"></span></p>
|
||||
</div>
|
||||
</template>
|
||||
<p x-show="!(task.acquisition_json && task.acquisition_json.camera_url)" class="text-[var(--text-muted)]">
|
||||
{{ _("Camera della stazione dell'operatore") }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Modifica: servizio, sorgente, modo (salvati con «Salva» del task) -->
|
||||
<template x-if="editingTask === task.id">
|
||||
<div class="space-y-3"
|
||||
x-data="taskAcquisition(() => editTaskData.acquisition, {{ {'genericError': _('Errore durante il caricamento delle sorgenti')}|tojson_attr }})">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="tmf-label text-xs">{{ _('Servizio di acquisizione') }}</label>
|
||||
<select class="tmf-input text-xs" x-model="editTaskData.acquisition.camera_url" @change="onServizio()">
|
||||
<option value="">{{ _("Camera della stazione dell'operatore") }}</option>
|
||||
<template x-for="sv in serviziConCorrente()" :key="sv.camera_url">
|
||||
<option :value="sv.camera_url"
|
||||
x-text="sv.camera_url + (sv.stazioni.length ? ' (' + sv.stazioni.join(', ') + ')' : '')"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div x-show="editTaskData.acquisition.camera_url">
|
||||
<label class="tmf-label text-xs">{{ _('Sorgente') }}</label>
|
||||
<select class="tmf-input text-xs" x-model="editTaskData.acquisition.camera_source" :disabled="loading">
|
||||
<option value="">{{ _('Scegli la sorgente') }}</option>
|
||||
<template x-for="src in sourcesConCorrente()" :key="src.name">
|
||||
<option :value="src.name" x-text="src.label || src.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p x-show="error" class="text-xs text-red-500" x-text="error"></p>
|
||||
<div x-show="editTaskData.acquisition.camera_url" class="flex flex-wrap items-center gap-4 text-xs">
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="radio" :name="'acq-modo-' + task.id" value="impulsiva" x-model="editTaskData.acquisition.modo">
|
||||
{{ _('Analisi impulsiva') }}
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="radio" :name="'acq-modo-' + task.id" value="in_linea" x-model="editTaskData.acquisition.modo">
|
||||
{{ _('Analisi in linea') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ========== Ricetta visione (#46): solo camera_measure / dxf_compare ========== -->
|
||||
<template x-if="task.task_type === 'camera_measure' || task.task_type === 'dxf_compare'">
|
||||
<div class="p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]"
|
||||
@@ -1335,6 +1400,7 @@
|
||||
<script src="{{ url_for('static', filename='js/vision_recipe.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/station_calibration.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/dxf_recipe_wizard.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/task_acquisition.js') }}"></script>{# #97 #}
|
||||
<script src="{{ url_for('static', filename='vendor/pdf-3.11.174.min.js') }}"></script>
|
||||
<script>if(typeof pdfjsLib!=='undefined')pdfjsLib.GlobalWorkerOptions.workerSrc='{{ url_for('static', filename='vendor/pdf.worker-3.11.174.min.js') }}';</script>
|
||||
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
|
||||
@@ -1389,7 +1455,7 @@ function taskEditor() {
|
||||
|
||||
// ---- Task CRUD state ----
|
||||
editingTask: null,
|
||||
editTaskData: { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [] },
|
||||
editTaskData: { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [], acquisition: { camera_url: '', camera_source: '', modo: 'impulsiva' } }, // #97
|
||||
newTask: { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [] },
|
||||
MAX_NOTE_FIELDS: 20, // #55: limite campi per nota (stesso del backend)
|
||||
_fieldUid: 0,
|
||||
@@ -1575,6 +1641,18 @@ function taskEditor() {
|
||||
this.newTask = { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [] };
|
||||
},
|
||||
|
||||
// #97: acquisition_json del task; null = camera della stazione (voce vuota o tipo senza camera)
|
||||
_acquisitionPayload(obj) {
|
||||
const a = obj.acquisition || {};
|
||||
const usesCamera = obj.task_type === 'camera_measure' || obj.task_type === 'dxf_compare';
|
||||
if (!usesCamera || !a.camera_url) return { ok: true, value: null };
|
||||
if (!a.camera_source) {
|
||||
this.errorMessage = {{ _("Scegli la sorgente della camera")|tojson }};
|
||||
return { ok: false };
|
||||
}
|
||||
return { ok: true, value: { camera_url: a.camera_url, camera_source: a.camera_source, modo: a.modo === 'in_linea' ? 'in_linea' : 'impulsiva' } };
|
||||
},
|
||||
|
||||
// ---- #53 #55: ciclo di misura e campi della nota ----
|
||||
|
||||
// #53: default di "nel ciclo" per tipo (stessa regola del backend)
|
||||
@@ -1785,7 +1863,13 @@ function taskEditor() {
|
||||
in_loop: task.in_loop != null ? !!task.in_loop : this.loopDefault(task.task_type || 'note'),
|
||||
in_loop_touched: false,
|
||||
at_close: !!task.at_close,
|
||||
fields: this._fieldsToState(task.fields)
|
||||
fields: this._fieldsToState(task.fields),
|
||||
// #97: camera e modo di analisi del task (acquisition_json), vuoti = camera della stazione
|
||||
acquisition: {
|
||||
camera_url: (task.acquisition_json && task.acquisition_json.camera_url) || '',
|
||||
camera_source: (task.acquisition_json && task.acquisition_json.camera_source) || '',
|
||||
modo: (task.acquisition_json && task.acquisition_json.modo) || 'impulsiva'
|
||||
}
|
||||
};
|
||||
// Ensure expanded
|
||||
this.expandedTask = task.id;
|
||||
@@ -1793,7 +1877,7 @@ function taskEditor() {
|
||||
|
||||
cancelEditTask() {
|
||||
this.editingTask = null;
|
||||
this.editTaskData = { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [] };
|
||||
this.editTaskData = { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [], acquisition: { camera_url: '', camera_source: '', modo: 'impulsiva' } }; // #97
|
||||
},
|
||||
|
||||
async updateTask(taskId) {
|
||||
@@ -1801,6 +1885,8 @@ function taskEditor() {
|
||||
this.errorMessage = '';
|
||||
const fp = this._fieldsPayload(this.editTaskData); // #55
|
||||
if (!fp.ok) return;
|
||||
const acq = this._acquisitionPayload(this.editTaskData); // #97
|
||||
if (!acq.ok) return;
|
||||
this.saving = true;
|
||||
|
||||
try {
|
||||
@@ -1818,7 +1904,8 @@ function taskEditor() {
|
||||
// #53 #55: ciclo di misura, compilazione a fine produzione, campi della nota
|
||||
in_loop: !!this.editTaskData.in_loop,
|
||||
at_close: this.editTaskData.task_type === 'note' && !!this.editTaskData.at_close,
|
||||
fields: fp.fields
|
||||
fields: fp.fields,
|
||||
acquisition_json: acq.value // #97
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user