acquisitore: immagini caricate come sorgenti e anteprima calibrata (Refs #92)
POST/DELETE /camera/immagini (cartella <config>/immagini), elenco sorgenti con le immagini e senza camera:file, apertura per nome di file con loop, frame grezzo tenuto dall'anteprima e POST /camera/anteprima/calibrata che raddrizza senza leggere un frame nuovo. 22 test nuovi. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
+109
-5
@@ -68,6 +68,9 @@ Uso (dalla radice del repo):
|
|||||||
[--host 127.0.0.1] [--port 8765] [--folder <cartella immagini>] \
|
[--host 127.0.0.1] [--port 8765] [--folder <cartella immagini>] \
|
||||||
[--config-dir <cartella calibrazioni>]
|
[--config-dir <cartella calibrazioni>]
|
||||||
--folder abilita la sorgente finta `camera:folder` (loop sulle immagini).
|
--folder abilita la sorgente finta `camera:folder` (loop sulle immagini).
|
||||||
|
Immagini caricate (#92): POST/DELETE /camera/immagini, in <config-dir>/immagini; sono sorgenti
|
||||||
|
a pieno titolo (/camera/sources, grab, anteprima, calibrate). POST /camera/anteprima/calibrata
|
||||||
|
raddrizza l'ultimo frame grezzo dell'anteprima con la calibrazione attiva, senza leggerne uno nuovo.
|
||||||
Da Docker su Linux: --host 172.17.0.1 (gateway di docker0).
|
Da Docker su Linux: --host 172.17.0.1 (gateway di docker0).
|
||||||
|
|
||||||
Dipende da: venv di VisionSuite (`uv sync --all-packages` in vendor/visionsuite):
|
Dipende da: venv di VisionSuite (`uv sync --all-packages` in vendor/visionsuite):
|
||||||
@@ -82,8 +85,10 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re # #92: nome sicuro per le immagini caricate
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
|
import urllib.parse # #92: nome calibrazione nell'header X-Calibrazione
|
||||||
import uuid # #91 id dei calcoli in attesa di nome
|
import uuid # #91 id dei calcoli in attesa di nome
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
@@ -96,7 +101,7 @@ from fastapi.concurrency import run_in_threadpool # #83: il grab bloccante fuor
|
|||||||
from fastapi.responses import HTMLResponse, Response
|
from fastapi.responses import HTMLResponse, Response
|
||||||
|
|
||||||
from visionsuite.camera.camera_interface import CameraError, CameraGrabError, CameraStateError
|
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)
|
# #83: calibrazione e pagina stanno accanto a questo file (che non e' un pacchetto)
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
@@ -148,6 +153,8 @@ def create_app(folder: Path | str | None = None,
|
|||||||
archivio = calibrazione.Archivio(config_dir if config_dir is not None
|
archivio = calibrazione.Archivio(config_dir if config_dir is not None
|
||||||
else _config_dir_default()) # #83
|
else _config_dir_default()) # #83
|
||||||
aperte: dict = {}
|
aperte: dict = {}
|
||||||
|
grezzi: dict = {} # #92: sorgente -> ultimo frame pieno GREZZO dell'anteprima (per anteprima/calibrata)
|
||||||
|
cartella_immagini = archivio.cartella / "immagini" # #92: immagini caricate dalla pagina (creata al bisogno)
|
||||||
lock = threading.Lock() # protegge SOLO i dizionari, mai un'apertura
|
lock = threading.Lock() # protegge SOLO i dizionari, mai un'apertura
|
||||||
lock_sorgenti: dict = {} # nome sorgente -> Lock che serializza apertura e grab
|
lock_sorgenti: dict = {} # nome sorgente -> Lock che serializza apertura e grab
|
||||||
calcoli: OrderedDict = OrderedDict() # #91: calcolo_id -> (dati, mappa); gli ultimi _MAX_CALCOLI
|
calcoli: OrderedDict = OrderedDict() # #91: calcolo_id -> (dati, mappa); gli ultimi _MAX_CALCOLI
|
||||||
@@ -179,15 +186,69 @@ def create_app(folder: Path | str | None = None,
|
|||||||
@app.get("/camera/sources")
|
@app.get("/camera/sources")
|
||||||
def camera_sources():
|
def camera_sources():
|
||||||
voci = list_sources(None, cameras=True, config=_config())
|
voci = list_sources(None, cameras=True, config=_config())
|
||||||
return {"sources": [{"name": v.name, "kind": v.kind, "label": v.label}
|
# #92: camera:file (replay) non e' mai configurato in TMF -> fuori dall'elenco
|
||||||
for v in voci if v.kind in ("camera", "virtual")]}
|
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
|
||||||
|
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)
|
||||||
|
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):
|
def _apri(nome: str):
|
||||||
"""#87: sorgente aperta (o da aprire); da chiamare SOLO sotto _lock_di(nome)."""
|
"""#87: sorgente aperta (o da aprire); da chiamare SOLO sotto _lock_di(nome)."""
|
||||||
with lock:
|
with lock:
|
||||||
sorgente = aperte.get(nome)
|
sorgente = aperte.get(nome)
|
||||||
if sorgente is None:
|
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
|
# TieVision-Core#341 / #89: free-run, serve la scena di adesso, non il frame vecchio in coda
|
||||||
if hasattr(sorgente, "solo_ultimo_frame"):
|
if hasattr(sorgente, "solo_ultimo_frame"):
|
||||||
try:
|
try:
|
||||||
@@ -414,7 +475,12 @@ def create_app(folder: Path | str | None = None,
|
|||||||
timeout = corpo.get("timeout_ms", 5000)
|
timeout = corpo.get("timeout_ms", 5000)
|
||||||
if isinstance(timeout, bool) or not isinstance(timeout, int) or not 1 <= timeout <= 10000:
|
if isinstance(timeout, bool) or not isinstance(timeout, int) or not 1 <= timeout <= 10000:
|
||||||
raise HTTPException(400, "«timeout_ms» deve essere un intero fra 1 e 10000")
|
raise HTTPException(400, "«timeout_ms» deve essere un intero fra 1 e 10000")
|
||||||
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
|
raddrizzato = False
|
||||||
if not grezzo: # come /camera/grab
|
if not grezzo: # come /camera/grab
|
||||||
mappa = archivio.leggi_mappa(nome)
|
mappa = archivio.leggi_mappa(nome)
|
||||||
@@ -442,6 +508,44 @@ def create_app(folder: Path | str | None = None,
|
|||||||
"X-Nitidezza": f"{nitidezza:.1f}", "X-Saturati-Pct": f"{saturati:.2f}",
|
"X-Nitidezza": f"{nitidezza:.1f}", "X-Saturati-Pct": f"{saturati:.2f}",
|
||||||
"X-Media": f"{media:.1f}"})
|
"X-Media": f"{media:.1f}"})
|
||||||
|
|
||||||
|
@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."""
|
||||||
|
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: # 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]
|
||||||
|
alt, larg = frame.shape[:2]
|
||||||
|
raise HTTPException(409, f"la calibrazione «{attiva}» è per immagini {cw}×{ch}, "
|
||||||
|
f"il frame è {larg}×{alt}")
|
||||||
|
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": "si", "X-Larghezza": str(larg), "X-Altezza": str(alt),
|
||||||
|
"X-Calibrazione": urllib.parse.quote(attiva, safe="")})
|
||||||
|
|
||||||
@app.get("/camera/parametri")
|
@app.get("/camera/parametri")
|
||||||
def parametri_leggi(source: str = ""):
|
def parametri_leggi(source: str = ""):
|
||||||
nome = _sorgente(source)
|
nome = _sorgente(source)
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
"""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_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_409(client):
|
||||||
|
_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)"
|
||||||
Reference in New Issue
Block a user