"""Test della calibrazione dell'acquisitore (#83): logica pura e rotte di camera_service. Uso: dalla radice, `uv run --python 3.13 --extra vision-worker --extra dev --with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q` (o il venv di VisionSuite come per test_camera_service.py). """ import base64 import importlib.util import json from pathlib import Path import cv2 import numpy as np import pytest from fastapi.testclient import TestClient SCRIPTS = Path(__file__).resolve().parents[1] MODULO = SCRIPTS / "camera_service.py" def _carica(): spec = importlib.util.spec_from_file_location("camera_service", MODULO) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod def una_scacchiera(square_px: int = 40, nx: int = 10, ny: int = 7, margin: int = 60) -> np.ndarray: """nx x ny quadrati => (nx-1) x (ny-1) angoli interni, su fondo bianco.""" img = np.full((ny * square_px + 2 * margin, nx * square_px + 2 * margin), 255, np.uint8) for j in range(ny): for i in range(nx): if (i + j) % 2 == 0: y, x = margin + j * square_px, margin + i * square_px img[y:y + square_px, x:x + square_px] = 0 return img def una_scacchiera_densa(w: int = 640, h: int = 480, square_px: int = 40, k: float = 1e-8) -> np.ndarray: """#84: scacchiera che riempie tutto il campo, con distorsione a barilotto (k sui px dal centro).""" m = 3 * square_px # la scacchiera continua oltre il campo: nessun bordo finto nell'immagine yy, xx = np.mgrid[0:h + 2 * m, 0:w + 2 * m] piana = (((xx // square_px) + (yy // square_px)) % 2 * 255).astype(np.uint8) cx, cy = w / 2.0, h / 2.0 dx, dy = (xx[m:m + h, m:m + w] - m - cx).astype(np.float32), (yy[m:m + h, m:m + w] - m - cy).astype(np.float32) f = 1 + k * (dx * dx + dy * dy) img = cv2.remap(piana, cx + m + dx * f, cy + m + dy * f, cv2.INTER_LINEAR) return cv2.GaussianBlur(img, (3, 3), 0.8) # bordi non perfettamente netti, come in camera def residuo_reticolo(griglia: np.ndarray) -> float: """Residuo massimo (px) della griglia (nr,nc,2) da un reticolo affine regolare.""" nr, nc, _ = griglia.shape ii, jj = np.mgrid[0:nr, 0:nc] A = np.stack([np.ones(nr * nc), ii.ravel(), jj.ravel()], axis=1) P = griglia.reshape(-1, 2) coef, *_ = np.linalg.lstsq(A, P, rcond=None) return float(np.hypot(*(A @ coef - P).T).max()) def _png(img: np.ndarray) -> bytes: ok, buf = cv2.imencode(".png", img) assert ok return buf.tobytes() @pytest.fixture def cartella(tmp_path): # la sorgente camera:folder fa il loop su una scacchiera: il grab la trova cartella = tmp_path / "immagini" cartella.mkdir() cv2.imwrite(str(cartella / "scacchiera.png"), una_scacchiera()) return cartella @pytest.fixture def client(cartella, tmp_path): mod = _carica() with TestClient(mod.create_app(cartella, config_dir=tmp_path / "config")) as c: c.config = tmp_path / "config" yield c CAMPI = {"cols": "9", "rows": "6", "square_mm": "2"} CHIAVI_C1 = {"px_per_mm", "mm_per_px", "rms_px", "n_corners", "cols", "rows", "square_mm", "image_width", "image_height", "scale_spread_pct", "calibrated_at"} # #84: campi in piu' per metodo planare (C2 con cols/rows) e per la densa CHIAVI_PLANARE = CHIAVI_C1 | {"metodo", "distorsione", "origine"} # #91: + origine ("camera:X" | "file:Y") CHIAVI_DENSA = CHIAVI_PLANARE | {"residuo_rms_px", "residuo_max_px", "copertura_pct"} SORGENTE = "camera:folder" # #91: GET /camera/calibration = voce di libreria + "nome" + "source" (la sorgente che la usa) CHIAVI_ATTIVA = {"source", "nome"} def salva_con_nome(client, calcolo: dict, nome: str, **extra): """#91: POST /camera/calibrazioni sul calcolo_id di una risposta di /camera/calibrate.""" return client.post("/camera/calibrazioni", json={"calcolo_id": calcolo["calcolo_id"], "nome": nome, **extra}) def carica_su(client, sorgente: str, nome: str): """#91: PUT /camera/calibrazione-attiva.""" return client.put("/camera/calibrazione-attiva", json={"source": sorgente, "nome": nome}) @pytest.fixture def cartella_densa(tmp_path): # #84: la sorgente camera:folder fa il loop su una scacchiera densa distorta cartella = tmp_path / "immagini_dense" cartella.mkdir() cv2.imwrite(str(cartella / "densa.png"), una_scacchiera_densa(k=1e-7)) return cartella @pytest.fixture def client_denso(cartella_densa, tmp_path): mod = _carica() with TestClient(mod.create_app(cartella_densa, config_dir=tmp_path / "config_densa")) as c: c.config = tmp_path / "config_densa" yield c NON_IMMAGINE = b"questo non e una immagine" # ---- logica pura def test_scacchiera_sintetica_da_la_scala(): _carica() # mette scripts/ in sys.path import calibrazione r = calibrazione.calibra_scacchiera(una_scacchiera(), 9, 6, 2.0) assert r["px_per_mm"] == pytest.approx(20.0, rel=0.005) assert r["mm_per_px"] == pytest.approx(0.05, rel=0.005) assert r["rms_px"] < 0.5 assert r["n_corners"] == 54 assert (r["image_width"], r["image_height"]) == (520, 400) assert r["scale_spread_pct"] < 1.0 assert base64.b64decode(r["overlay_png"])[:8] == b"\x89PNG\r\n\x1a\n" def test_immagine_bianca_solleva(): _carica() import calibrazione with pytest.raises(calibrazione.CalibrazioneError, match="non trovata"): calibrazione.calibra_scacchiera(np.full((300, 300), 255, np.uint8), 9, 6, 2.0) @pytest.mark.parametrize("cols,rows,quadrato", [(1, 6, 2.0), (9, 6, 0.0), (9, 6, -1.0)]) def test_parametri_invalidi_sollevano(cols, rows, quadrato): _carica() import calibrazione with pytest.raises(calibrazione.CalibrazioneError): calibrazione.calibra_scacchiera(una_scacchiera(), cols, rows, quadrato) # ---- rotte def test_c1_non_calibrata_404(client): r = client.get("/camera/calibration", params={"source": "camera:folder"}) assert r.status_code == 404 assert r.json()["detail"] == "sorgente non calibrata" def test_c1_senza_source_400(client): assert client.get("/camera/calibration").status_code == 400 def test_c2_immagine_caricata_senza_salvare(client): r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI}, files={"image": ("s.png", _png(una_scacchiera()), "image/png")}) assert r.status_code == 200, r.text corpo = r.json() assert CHIAVI_PLANARE | {"overlay_png", "calcolo_id"} == set(corpo) # #91: niente "saved", niente "source" assert corpo["metodo"] == "planare" and corpo["distorsione"] is False # #84: con cols/rows nulla cambia assert corpo["origine"] == "file:s.png" assert corpo["px_per_mm"] == pytest.approx(20.0, rel=0.005) # il calcolo non salva mai assert client.get("/camera/calibration", params={"source": "camera:folder"}).status_code == 404 assert not client.config.exists() or not list(client.config.iterdir()) def test_c2_save_true_viene_ignorato(client): # #91: il vecchio campo save non salva piu' nulla r = client.post("/camera/calibrate", data={"source": "camera:folder", "save": "true", **CAMPI}) assert r.status_code == 200, r.text assert client.get("/camera/calibration", params={"source": "camera:folder"}).status_code == 404 assert client.get("/camera/calibrazioni").json()["calibrazioni"] == [] assert not client.config.exists() or not list(client.config.iterdir()) def test_c2_c1_c3_ciclo_con_grab_dalla_sorgente(client): # nessun file: grab da camera:folder (la scacchiera in cartella) r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI}) assert r.status_code == 200, r.text assert r.json()["origine"] == "camera:folder" assert salva_con_nome(client, r.json(), "Banco").status_code == 201 # #91: si salva con nome, dopo # salvata ma non ancora caricata sulla sorgente assert client.get("/camera/calibration", params={"source": "camera:folder"}).status_code == 404 assert carica_su(client, "camera:folder", "Banco").status_code == 200 r1 = client.get("/camera/calibration", params={"source": "camera:folder"}) assert r1.status_code == 200 assert set(r1.json()) == CHIAVI_PLANARE | CHIAVI_ATTIVA # nessun overlay nel file salvato assert r1.json()["px_per_mm"] == pytest.approx(20.0, rel=0.005) assert r1.json()["source"] == "camera:folder" and r1.json()["nome"] == "Banco" file_salvati = list((client.config / "calibrazioni").glob("*.json")) assert len(file_salvati) == 1 assert json.loads(file_salvati[0].read_text(encoding="utf-8"))["cols"] == 9 # DELETE /camera/calibration scollega: la libreria resta assert client.delete("/camera/calibration", params={"source": "camera:folder"}).status_code == 204 assert client.get("/camera/calibration", params={"source": "camera:folder"}).status_code == 404 assert len(list((client.config / "calibrazioni").glob("*.json"))) == 1 # scollegare di nuovo e' idempotente assert client.delete("/camera/calibration", params={"source": "camera:folder"}).status_code == 204 def test_c2_scacchiera_non_trovata_422(client): bianca = np.full((300, 300), 255, np.uint8) r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI}, files={"image": ("b.png", _png(bianca), "image/png")}) assert r.status_code == 422 assert "non trovata" in r.json()["detail"] def test_c2_parametri_invalidi_422(client): r = client.post("/camera/calibrate", data={"source": "camera:folder", "cols": "1", "rows": "6", "square_mm": "2"}, files={"image": ("s.png", _png(una_scacchiera()), "image/png")}) assert r.status_code == 422 def test_c2_immagine_illeggibile_422(client): r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI}, files={"image": ("x.png", NON_IMMAGINE, "image/png")}) assert r.status_code == 422 def test_c2_errori_camera_come_grab(client): r = client.post("/camera/calibrate", data={"source": "camera:non-esiste", **CAMPI}) assert r.status_code == 404 def test_due_nomi_simili_hanno_file_diversi(client): # #91: i nomi si ripuliscono come prima (hash sul nome originale): due nomi diversi non si pestano r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI}, files={"image": ("s.png", _png(una_scacchiera()), "image/png")}) assert r.status_code == 200 for nome in ("banco a b", "banco a_b"): assert salva_con_nome(client, r.json(), nome).status_code == 201 assert len(list((client.config / "calibrazioni").glob("*.json"))) == 2 def test_c4_pagina_html(client): r = client.get("/calibrazione") assert r.status_code == 200 assert r.headers["content-type"].startswith("text/html") assert "Acquisisci e calibra" in r.text assert "Lato dello scacco" in r.text and 'id="cols"' not in r.text # #84: un solo campo assert "http://" not in r.text and "https://" not in r.text # offline, niente CDN # ---- #84: calibrazione densa (logica pura) def test_densa_trova_la_griglia_da_sola_e_la_scala(): _carica() import calibrazione r, mappa = calibrazione.calibra_scacchiera_densa(una_scacchiera_densa(k=1e-8), 2.0) assert (r["cols"], r["rows"]) == (15, 11) # 640x480 con quadrati da 40 px assert r["n_corners"] == 15 * 11 assert r["mm_per_px"] == pytest.approx(2.0 / 40, rel=0.005) assert r["px_per_mm"] == pytest.approx(20.0, rel=0.005) assert r["metodo"] == "densa" and r["distorsione"] is True assert r["residuo_max_px"] < 0.3 and r["residuo_rms_px"] <= r["residuo_max_px"] assert 60 < r["copertura_pct"] <= 100 assert (r["image_width"], r["image_height"]) == (640, 480) assert base64.b64decode(r["overlay_png"])[:8] == b"\x89PNG\r\n\x1a\n" assert mappa.shape == (480, 640) and mappa.pixel_size == pytest.approx(0.05, rel=0.005) def test_densa_raddrizza_su_griglia_regolare(): _carica() import calibrazione img = una_scacchiera_densa(k=1e-7) # distorsione marcata: ai bordi ~5 % r, mappa = calibrazione.calibra_scacchiera_densa(img, 2.0) assert (r["cols"], r["rows"]) == (15, 11) # prima la griglia e' curva, dopo e' un reticolo regolare assert residuo_reticolo(calibrazione._trova_griglia(img)) > 1.0 pronta = calibrazione._prepara_mappa(mappa) dritta = calibrazione.raddrizza(img, pronta) assert dritta.shape == img.shape assert residuo_reticolo(calibrazione._trova_griglia(dritta)) < 0.3 @pytest.mark.parametrize("cosa", ["bianca", "scacchiera_piccola"]) def test_densa_senza_griglia_solleva(cosa): _carica() import calibrazione img = (np.full((300, 300), 255, np.uint8) if cosa == "bianca" else una_scacchiera_densa(80, 60, 20)) # 3x2 angoli: meno di 3x3 with pytest.raises(calibrazione.CalibrazioneError, match="non trovata"): calibrazione.calibra_scacchiera_densa(img, 2.0) @pytest.mark.parametrize("quadrato", [0.0, -1.0, float("nan")]) def test_densa_quadrato_invalido_solleva(quadrato): _carica() import calibrazione with pytest.raises(calibrazione.CalibrazioneError): calibrazione.calibra_scacchiera_densa(una_scacchiera_densa(), quadrato) def test_archivio_mappa_cache_e_invalidazione(tmp_path): _carica() import calibrazione _, mappa = calibrazione.calibra_scacchiera_densa(una_scacchiera_densa(), 2.0) arch = calibrazione.Archivio(tmp_path) assert arch.leggi_mappa("s") is None arch.salva_nome("cal", {"a": 1}, mappa) # #91: salvataggio con nome + attivazione sulla sorgente arch.attiva("s", "cal") prima = arch.leggi_mappa("s") assert prima is not None and arch.leggi_mappa("s") is prima # cache in memoria assert not list(tmp_path.rglob("*.tmp*")) # niente temporanei rimasti arch.salva_nome("cal", {"a": 2}, mappa, sovrascrivi=True) # file riscritto -> cache invalidata assert arch.leggi_mappa("s") is not prima arch.salva_nome("cal", {"a": 3}, sovrascrivi=True) # salvataggio senza mappa (planare): la vecchia sparisce assert arch.leggi_mappa("s") is None and not list(tmp_path.rglob("*.npz")) # ---- #84: rotte con la calibrazione densa def _grigio(r) -> np.ndarray: return cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_GRAYSCALE) def test_c2_senza_cols_rows_e_densa(client_denso): r = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"}) assert r.status_code == 200, r.text corpo = r.json() assert CHIAVI_DENSA | {"overlay_png", "calcolo_id"} == set(corpo) assert corpo["metodo"] == "densa" and corpo["distorsione"] is True assert (corpo["cols"], corpo["rows"]) == (15, 11) assert corpo["px_per_mm"] == pytest.approx(1.0 / corpo["mm_per_px"]) assert not client_denso.config.exists() or not list(client_denso.config.iterdir()) def test_c2_solo_cols_o_solo_rows_422(client_denso): r = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2", "cols": "9"}) assert r.status_code == 422 def test_grab_raddrizzato_se_calibrata_e_grezzo_a_richiesta(client_denso, cartella_densa): originale = cv2.imread(str(cartella_densa / "densa.png"), cv2.IMREAD_GRAYSCALE) prima = client_denso.post("/camera/grab", json={"source": SORGENTE}) assert prima.headers["x-raddrizzato"] == "no" # non calibrata: frame com'e' assert np.array_equal(_grigio(prima), originale) r = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"}) assert r.status_code == 200, r.text assert salva_con_nome(client_denso, r.json(), "Densa").status_code == 201 # #91: salvata con nome libreria = client_denso.config / "calibrazioni" json_salvati, mappe = list(libreria.glob("*.json")), list(libreria.glob("*.npz")) assert len(json_salvati) == 1 and len(mappe) == 1 assert mappe[0].stem == json_salvati[0].stem # stesso nome del JSON assert client_denso.post("/camera/grab", json={"source": SORGENTE}).headers["x-raddrizzato"] == "no" # salvata != attiva assert carica_su(client_denso, SORGENTE, "Densa").status_code == 200 saved = client_denso.get("/camera/calibration", params={"source": SORGENTE}).json() assert set(saved) == CHIAVI_DENSA | CHIAVI_ATTIVA and saved["metodo"] == "densa" dopo = client_denso.post("/camera/grab", json={"source": SORGENTE}) assert dopo.status_code == 200 and dopo.headers["x-raddrizzato"] == "si" import calibrazione dritta = _grigio(dopo) assert dritta.shape == originale.shape assert not np.array_equal(dritta, originale) assert residuo_reticolo(calibrazione._trova_griglia(dritta)) < 0.3 # griglia regolare grezzo = client_denso.post("/camera/grab", json={"source": SORGENTE, "grezzo": True}) assert grezzo.headers["x-raddrizzato"] == "no" assert np.array_equal(_grigio(grezzo), originale) # la calibrazione parte sempre dal frame grezzo: ricalibrare dalla sorgente da' lo stesso mm/px di_nuovo = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"}) assert di_nuovo.json()["mm_per_px"] == pytest.approx(r.json()["mm_per_px"]) assert di_nuovo.json()["residuo_max_px"] < 0.3 def test_grab_grezzo_non_booleano_400(client_denso): r = client_denso.post("/camera/grab", json={"source": SORGENTE, "grezzo": "si"}) assert r.status_code == 400 def test_scollega_lascia_la_libreria_ed_elimina_toglie_la_mappa(client_denso): r = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"}) salva_con_nome(client_denso, r.json(), "Densa") carica_su(client_denso, SORGENTE, "Densa") libreria = client_denso.config / "calibrazioni" assert list(libreria.glob("*.npz")) # scollegare: il frame torna com'e', ma la voce (e la mappa) restano in libreria assert client_denso.delete("/camera/calibration", params={"source": SORGENTE}).status_code == 204 assert client_denso.post("/camera/grab", json={"source": SORGENTE}).headers["x-raddrizzato"] == "no" assert list(libreria.glob("*.npz")) and list(libreria.glob("*.json")) # eliminare la voce toglie JSON e mappa carica_su(client_denso, SORGENTE, "Densa") assert client_denso.delete("/camera/calibrazioni", params={"nome": "Densa"}).status_code == 204 assert not list(libreria.glob("*.npz")) and not list(libreria.glob("*.json")) assert client_denso.post("/camera/grab", json={"source": SORGENTE}).headers["x-raddrizzato"] == "no" def test_ricalibrare_planare_toglie_la_mappa_densa(client_denso): densa = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"}) salva_con_nome(client_denso, densa.json(), "Cal") carica_su(client_denso, SORGENTE, "Cal") libreria = client_denso.config / "calibrazioni" assert list(libreria.glob("*.npz")) planare = client_denso.post("/camera/calibrate", data={"source": SORGENTE, **CAMPI}, files={"image": ("s.png", _png(una_scacchiera()), "image/png")}) assert planare.status_code == 200 and planare.json()["metodo"] == "planare" assert salva_con_nome(client_denso, planare.json(), "Cal").status_code == 409 # nome gia' preso assert salva_con_nome(client_denso, planare.json(), "Cal", sovrascrivi=True).status_code == 201 assert not list(libreria.glob("*.npz")) # niente mappa vecchia che raddrizza a vuoto assert client_denso.post("/camera/grab", json={"source": SORGENTE}).headers["x-raddrizzato"] == "no"