351739ec36
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
185 lines
6.9 KiB
Python
185 lines
6.9 KiB
Python
"""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 _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 = {"source", "px_per_mm", "mm_per_px", "rms_px", "n_corners", "cols", "rows",
|
|
"square_mm", "image_width", "image_height", "scale_spread_pct", "calibrated_at"}
|
|
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_C1 | {"overlay_png", "saved"} == set(corpo)
|
|
assert corpo["saved"] is False
|
|
assert corpo["px_per_mm"] == pytest.approx(20.0, rel=0.005)
|
|
# senza save non si scrive nulla
|
|
assert client.get("/camera/calibration", params={"source": "camera:folder"}).status_code == 404
|
|
assert not client.config.exists() or not list(client.config.glob("*.json"))
|
|
|
|
|
|
def test_c2_c1_c3_ciclo_con_grab_dalla_sorgente(client):
|
|
# nessun file: grab da camera:folder (la scacchiera in cartella), con save
|
|
r = client.post("/camera/calibrate",
|
|
data={"source": "camera:folder", "save": "true", **CAMPI})
|
|
assert r.status_code == 200, r.text
|
|
assert r.json()["saved"] is True
|
|
|
|
r1 = client.get("/camera/calibration", params={"source": "camera:folder"})
|
|
assert r1.status_code == 200
|
|
assert set(r1.json()) == CHIAVI_C1 # nessun overlay nel file salvato
|
|
assert r1.json()["px_per_mm"] == pytest.approx(20.0, rel=0.005)
|
|
assert r1.json()["source"] == "camera:folder"
|
|
file_salvati = list(client.config.glob("*.json"))
|
|
assert len(file_salvati) == 1
|
|
assert json.loads(file_salvati[0].read_text(encoding="utf-8"))["cols"] == 9
|
|
|
|
assert client.delete("/camera/calibration", params={"source": "camera:folder"}).status_code == 204
|
|
assert client.get("/camera/calibration", params={"source": "camera:folder"}).status_code == 404
|
|
# cancellare 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_sorgenti_hanno_file_diversi(client):
|
|
for nome in ("camera:a b", "camera:a_b"):
|
|
r = client.post("/camera/calibrate", data={"source": nome, "save": "true", **CAMPI},
|
|
files={"image": ("s.png", _png(una_scacchiera()), "image/png")})
|
|
assert r.status_code == 200
|
|
assert len(list(client.config.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 calcola" in r.text
|
|
assert "http://" not in r.text and "https://" not in r.text # offline, niente CDN
|