"""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)"