Acquisitore: calibrazione densa su C2/grab/pagina, test (#84)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
This commit is contained in:
@@ -3,6 +3,8 @@
|
|||||||
Cosa fa: una pagina sola, autonoma e offline (niente CDN, JS e CSS inline), in
|
Cosa fa: una pagina sola, autonoma e offline (niente CDN, JS e CSS inline), in
|
||||||
italiano. Parla con le rotte dello stesso servizio (/camera/sources,
|
italiano. Parla con le rotte dello stesso servizio (/camera/sources,
|
||||||
/camera/calibration, /camera/calibrate).
|
/camera/calibration, /camera/calibrate).
|
||||||
|
#84: calibrazione densa, un solo dato dell'operatore (lato dello scacco in mm):
|
||||||
|
scacchiera che riempie il campo, griglia trovata da sola, mappa di distorsione.
|
||||||
Dipende da: niente. E' una stringa, cosi' il pacchetto compilato non ha file dati.
|
Dipende da: niente. E' una stringa, cosi' il pacchetto compilato non ha file dati.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -41,20 +43,18 @@ PAGINA = """<!doctype html>
|
|||||||
<div class="scheda">
|
<div class="scheda">
|
||||||
<div class="riga">
|
<div class="riga">
|
||||||
<div><label for="sorgente">Sorgente</label><select id="sorgente"></select></div>
|
<div><label for="sorgente">Sorgente</label><select id="sorgente"></select></div>
|
||||||
<div><label for="cols">Angoli interni in orizzontale</label><input id="cols" type="number" min="2" value="9"></div>
|
<div><label for="quadrato">Lato dello scacco (mm)</label><input id="quadrato" type="number" min="0.001" step="any" value="2"></div>
|
||||||
<div><label for="rows">Angoli interni in verticale</label><input id="rows" type="number" min="2" value="6"></div>
|
|
||||||
<div><label for="quadrato">Lato del quadrato (mm)</label><input id="quadrato" type="number" min="0.001" step="any" value="2"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<p style="font-size:.85rem;color:#4b5563;margin:10px 0 0">
|
<p style="font-size:.85rem;color:#4b5563;margin:10px 0 0">
|
||||||
Gli angoli interni sono quelli dove si toccano quattro quadrati: una scacchiera di 10 x 7 quadrati ha 9 x 6 angoli.
|
Servono una scacchiera che riempie tutto il campo della camera, piana, ferma e ben illuminata, e il lato di uno scacco.
|
||||||
Tenere la scacchiera piana e ferma, ben illuminata.</p>
|
La griglia si trova da sola; il risultato corregge anche la distorsione dell'ottica.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="scheda">
|
<div class="scheda">
|
||||||
<div class="riga">
|
<div class="riga">
|
||||||
<button id="acquisisci">Acquisisci e calcola</button>
|
<button id="acquisisci">Acquisisci e calibra</button>
|
||||||
<div><label for="file">oppure un file immagine</label><input id="file" type="file" accept="image/*"></div>
|
<div><label for="file">oppure un file immagine</label><input id="file" type="file" accept="image/*"></div>
|
||||||
<button id="carica" class="secondario">Carica immagine e calcola</button>
|
<button id="carica" class="secondario">Carica immagine e calibra</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="messaggio" class="messaggio"></div>
|
<div id="messaggio" class="messaggio"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -64,7 +64,7 @@ PAGINA = """<!doctype html>
|
|||||||
<table id="tabella"></table>
|
<table id="tabella"></table>
|
||||||
<div id="avviso" class="avviso"></div>
|
<div id="avviso" class="avviso"></div>
|
||||||
<div class="riga" style="margin-top:10px">
|
<div class="riga" style="margin-top:10px">
|
||||||
<button id="salva">Salva per questa sorgente</button>
|
<button id="salva">Salva</button>
|
||||||
<span style="font-size:.85rem;color:#4b5563">Da camera, salvando si acquisisce e si calcola di nuovo: tenere la scacchiera ferma.</span>
|
<span style="font-size:.85rem;color:#4b5563">Da camera, salvando si acquisisce e si calcola di nuovo: tenere la scacchiera ferma.</span>
|
||||||
</div>
|
</div>
|
||||||
<img id="overlay" alt="Angoli trovati sulla scacchiera">
|
<img id="overlay" alt="Angoli trovati sulla scacchiera">
|
||||||
@@ -95,12 +95,19 @@ function numero(v, cifre) { return Number(v).toFixed(cifre); }
|
|||||||
function righe(d) {
|
function righe(d) {
|
||||||
return [
|
return [
|
||||||
["Scala", numero(d.px_per_mm, 4) + " px/mm (" + numero(d.mm_per_px, 5) + " mm/px)"],
|
["Scala", numero(d.px_per_mm, 4) + " px/mm (" + numero(d.mm_per_px, 5) + " mm/px)"],
|
||||||
|
["Metodo", d.metodo !== "densa" ? "planare (senza correzione della distorsione)" : "densa (immagine raddrizzata)"],
|
||||||
|
["Griglia trovata", d.cols + " x " + d.rows + " angoli (" + d.n_corners + ")"]
|
||||||
|
].concat(d.metodo !== "densa" ? [
|
||||||
["Errore di riproiezione (RMS)", numero(d.rms_px, 3) + " px"],
|
["Errore di riproiezione (RMS)", numero(d.rms_px, 3) + " px"],
|
||||||
["Variazione di scala nell'area", numero(d.scale_spread_pct, 2) + " %"],
|
["Variazione di scala nell'area", numero(d.scale_spread_pct, 2) + " %"]
|
||||||
["Angoli trovati", d.n_corners + " (" + d.cols + " x " + d.rows + ")"],
|
] : [
|
||||||
["Lato del quadrato", d.square_mm + " mm"],
|
["Residuo massimo", numero(d.residuo_max_px, 3) + " px"],
|
||||||
|
["Residuo RMS", numero(d.residuo_rms_px, 3) + " px"],
|
||||||
|
["Copertura del campo", numero(d.copertura_pct, 1) + " %"]
|
||||||
|
]).concat([
|
||||||
|
["Lato dello scacco", d.square_mm + " mm"],
|
||||||
["Immagine", d.image_width + " x " + d.image_height + " px"]
|
["Immagine", d.image_width + " x " + d.image_height + " px"]
|
||||||
];
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function tabella(el, d) {
|
function tabella(el, d) {
|
||||||
@@ -157,9 +164,19 @@ async function aggiornaStato() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parametri() {
|
function parametri() {
|
||||||
return {
|
return { quadrato: $("quadrato").value };
|
||||||
cols: $("cols").value, rows: $("rows").value, quadrato: $("quadrato").value
|
}
|
||||||
};
|
|
||||||
|
// avvisi sulla qualita' (solo calibrazione densa): copertura bassa o residuo alto
|
||||||
|
function avvisi(d) {
|
||||||
|
var a = [];
|
||||||
|
if (d.metodo !== "densa") {
|
||||||
|
if (d.scale_spread_pct > 1) a.push("Attenzione: la scala varia di " + numero(d.scale_spread_pct, 2) + " % nell'area: scacchiera inclinata o ottica non telecentrica.");
|
||||||
|
} else {
|
||||||
|
if (d.copertura_pct < 70) a.push("Attenzione: la scacchiera copre solo il " + numero(d.copertura_pct, 1) + " % del campo (meno del 70 %): la correzione ai bordi non e' misurata.");
|
||||||
|
if (d.residuo_max_px > 0.5) a.push("Attenzione: residuo massimo " + numero(d.residuo_max_px, 3) + " px (oltre 0,5 px): scacchiera non piana, sfocata o con scacchi irregolari.");
|
||||||
|
}
|
||||||
|
return a.join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function calcola(conFile, salva) {
|
async function calcola(conFile, salva) {
|
||||||
@@ -168,8 +185,6 @@ async function calcola(conFile, salva) {
|
|||||||
var p = parametri();
|
var p = parametri();
|
||||||
var f = new FormData();
|
var f = new FormData();
|
||||||
f.append("source", sorgente);
|
f.append("source", sorgente);
|
||||||
f.append("cols", p.cols);
|
|
||||||
f.append("rows", p.rows);
|
|
||||||
f.append("square_mm", p.quadrato);
|
f.append("square_mm", p.quadrato);
|
||||||
f.append("save", salva ? "true" : "false");
|
f.append("save", salva ? "true" : "false");
|
||||||
if (conFile) {
|
if (conFile) {
|
||||||
@@ -186,11 +201,9 @@ async function calcola(conFile, salva) {
|
|||||||
ultimo = { conFile: conFile, dati: d };
|
ultimo = { conFile: conFile, dati: d };
|
||||||
tabella($("tabella"), d);
|
tabella($("tabella"), d);
|
||||||
$("overlay").src = "data:image/png;base64," + d.overlay_png;
|
$("overlay").src = "data:image/png;base64," + d.overlay_png;
|
||||||
$("avviso").textContent = d.scale_spread_pct > 1
|
$("avviso").textContent = avvisi(d);
|
||||||
? "Attenzione: la scala varia di " + numero(d.scale_spread_pct, 2) + " % nell'area: scacchiera inclinata o ottica non telecentrica."
|
|
||||||
: "";
|
|
||||||
$("risultato").hidden = false;
|
$("risultato").hidden = false;
|
||||||
messaggio(d.saved ? "Calibrazione salvata." : "Calcolo riuscito: controllare e salvare.", "ok");
|
messaggio(d.saved ? "Calibrazione salvata." : "Calibrazione riuscita: controllare e salvare.", "ok");
|
||||||
if (d.saved) aggiornaStato();
|
if (d.saved) aggiornaStato();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
messaggio("Servizio non raggiungibile.", "errore");
|
messaggio("Servizio non raggiungibile.", "errore");
|
||||||
|
|||||||
+37
-10
@@ -4,18 +4,27 @@ Cosa fa: espone via HTTP l'acquisizione di una camera (vs-camera: GigE/GenTL,
|
|||||||
OpenCV, cartella di immagini) con lo stesso contratto di `vs-gui`, ma solo
|
OpenCV, cartella di immagini) con lo stesso contratto di `vs-gui`, ma solo
|
||||||
`grab` (copiato da TieVision-Studio, src/visionsuite/gui/server.py):
|
`grab` (copiato da TieVision-Studio, src/visionsuite/gui/server.py):
|
||||||
GET /camera/sources -> {"sources": [{name, kind, label}]}
|
GET /camera/sources -> {"sources": [{name, kind, label}]}
|
||||||
POST /camera/grab json {source, trigger="continuo", timeout_ms=5000} -> PNG
|
POST /camera/grab json {source, trigger="continuo", timeout_ms=5000,
|
||||||
|
grezzo=false} -> PNG
|
||||||
|
#84: se la sorgente ha una mappa di distorsione salvata il frame esce
|
||||||
|
raddrizzato (header X-Raddrizzato: si|no); "grezzo": true lo dà com'è.
|
||||||
Errori: 400 parametri, 404 sorgente inesistente, 503 guasto camera, 504 timeout.
|
Errori: 400 parametri, 404 sorgente inesistente, 503 guasto camera, 504 timeout.
|
||||||
|
|
||||||
Calibrazione a scacchiera per sorgente (#83, logica in scripts/calibrazione.py):
|
Calibrazione a scacchiera per sorgente (#83, logica in scripts/calibrazione.py):
|
||||||
GET /camera/calibration?source=<src> -> 200 {source, px_per_mm, mm_per_px,
|
GET /camera/calibration?source=<src> -> 200 {source, px_per_mm, mm_per_px,
|
||||||
rms_px, n_corners, cols, rows, square_mm, image_width, image_height,
|
rms_px, n_corners, cols, rows, square_mm, image_width, image_height,
|
||||||
scale_spread_pct, calibrated_at} | 404 "sorgente non calibrata"
|
scale_spread_pct, calibrated_at} | 404 "sorgente non calibrata"
|
||||||
POST /camera/calibrate multipart: source, cols, rows (angoli interni),
|
POST /camera/calibrate multipart: source, square_mm, image (file, opzionale:
|
||||||
square_mm, image (file, opzionale: senza, grab dalla sorgente come
|
senza, grab GREZZO dalla sorgente), save=false, cols/rows (opzionali).
|
||||||
/camera/grab), save=false -> campi di sopra + overlay_png (b64) + saved;
|
#84: SENZA cols/rows = calibrazione densa (default): una foto di
|
||||||
|
scacchiera che riempie il campo, griglia trovata da sola, mappa di
|
||||||
|
distorsione salvata accanto al JSON (.npz); mm/px e px/mm sono quelli
|
||||||
|
dell'immagine RADDRIZZATA. CON cols/rows (angoli interni) = metodo
|
||||||
|
planare di prima. Campi: quelli di C1 + overlay_png (b64) + saved +
|
||||||
|
metodo ("densa"|"planare"), distorsione (bool) e, per la densa,
|
||||||
|
residuo_rms_px, residuo_max_px, copertura_pct;
|
||||||
422 scacchiera non trovata o parametri invalidi
|
422 scacchiera non trovata o parametri invalidi
|
||||||
DELETE /camera/calibration?source=<src> -> 204
|
DELETE /camera/calibration?source=<src> -> 204 (toglie JSON e mappa)
|
||||||
GET /calibrazione -> pagina HTML autonoma (offline) per fare tutto a mano
|
GET /calibrazione -> pagina HTML autonoma (offline) per fare tutto a mano
|
||||||
Il risultato salvato sta in un JSON per sorgente in --config-dir (default
|
Il risultato salvato sta in un JSON per sorgente in --config-dir (default
|
||||||
%ProgramData%/Tielogic/TieMeasureFlow/acquisitore su Windows, ./acquisitore altrove).
|
%ProgramData%/Tielogic/TieMeasureFlow/acquisitore su Windows, ./acquisitore altrove).
|
||||||
@@ -158,14 +167,25 @@ def create_app(folder: Path | str | None = None,
|
|||||||
trigger = corpo.get("trigger", "continuo")
|
trigger = corpo.get("trigger", "continuo")
|
||||||
if trigger not in _TRIGGER:
|
if trigger not in _TRIGGER:
|
||||||
raise HTTPException(400, "«trigger» supportato: solo continuo")
|
raise HTTPException(400, "«trigger» supportato: solo continuo")
|
||||||
|
grezzo = corpo.get("grezzo", False) # #84: true = frame non raddrizzato
|
||||||
|
if not isinstance(grezzo, bool):
|
||||||
|
raise HTTPException(400, "«grezzo» deve essere true o false")
|
||||||
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) # #83: corpo estratto in _grab, invariato
|
frame = _grab(nome, timeout) # #83: corpo estratto in _grab, invariato
|
||||||
|
raddrizzato = False
|
||||||
|
if not grezzo: # #84: sorgente calibrata (densa) -> frame raddrizzato dalla mappa in cache
|
||||||
|
mappa = archivio.leggi_mappa(nome)
|
||||||
|
if mappa is not None:
|
||||||
|
corretto = calibrazione.raddrizza(frame, mappa)
|
||||||
|
if corretto is not None: # None = dimensioni diverse da quelle calibrate
|
||||||
|
frame, raddrizzato = corretto, True
|
||||||
ok, buf = cv2.imencode(".png", frame)
|
ok, buf = cv2.imencode(".png", frame)
|
||||||
if not ok:
|
if not ok:
|
||||||
raise HTTPException(500, "codifica PNG fallita")
|
raise HTTPException(500, "codifica PNG fallita")
|
||||||
return Response(buf.tobytes(), media_type="image/png")
|
return Response(buf.tobytes(), media_type="image/png",
|
||||||
|
headers={"X-Raddrizzato": "si" if raddrizzato else "no"})
|
||||||
|
|
||||||
# ---- #83: calibrazione a scacchiera per sorgente
|
# ---- #83: calibrazione a scacchiera per sorgente
|
||||||
|
|
||||||
@@ -189,9 +209,9 @@ def create_app(folder: Path | str | None = None,
|
|||||||
@app.post("/camera/calibrate")
|
@app.post("/camera/calibrate")
|
||||||
async def calibrazione_calcola(
|
async def calibrazione_calcola(
|
||||||
source: str = Form(...),
|
source: str = Form(...),
|
||||||
cols: int = Form(...),
|
|
||||||
rows: int = Form(...),
|
|
||||||
square_mm: float = Form(...),
|
square_mm: float = Form(...),
|
||||||
|
cols: int | None = Form(None), # #84: opzionali, senza = calibrazione densa
|
||||||
|
rows: int | None = Form(None),
|
||||||
image: UploadFile | None = File(None),
|
image: UploadFile | None = File(None),
|
||||||
save: bool = Form(False),
|
save: bool = Form(False),
|
||||||
):
|
):
|
||||||
@@ -203,15 +223,22 @@ def create_app(folder: Path | str | None = None,
|
|||||||
raise HTTPException(422, "l'immagine caricata non è leggibile")
|
raise HTTPException(422, "l'immagine caricata non è leggibile")
|
||||||
else:
|
else:
|
||||||
frame = await run_in_threadpool(_grab, nome, 5000) # come /camera/grab (404/503/504)
|
frame = await run_in_threadpool(_grab, nome, 5000) # come /camera/grab (404/503/504)
|
||||||
|
if (cols is None) != (rows is None):
|
||||||
|
raise HTTPException(422, "cols e rows vanno indicati insieme (o nessuno dei due)")
|
||||||
|
mappa = None
|
||||||
try:
|
try:
|
||||||
risultato = calibrazione.calibra_scacchiera(frame, cols, rows, square_mm)
|
if cols is None: # #84: densa, nessuna griglia da dichiarare
|
||||||
|
risultato, mappa = await run_in_threadpool(
|
||||||
|
calibrazione.calibra_scacchiera_densa, frame, square_mm)
|
||||||
|
else: # metodo planare di #83, invariato
|
||||||
|
risultato = calibrazione.calibra_scacchiera(frame, cols, rows, square_mm)
|
||||||
except calibrazione.CalibrazioneError as e:
|
except calibrazione.CalibrazioneError as e:
|
||||||
raise HTTPException(422, str(e))
|
raise HTTPException(422, str(e))
|
||||||
corpo = {"source": nome,
|
corpo = {"source": nome,
|
||||||
**{k: v for k, v in risultato.items() if k != "overlay_png"},
|
**{k: v for k, v in risultato.items() if k != "overlay_png"},
|
||||||
"calibrated_at": calibrazione.ora_iso()}
|
"calibrated_at": calibrazione.ora_iso()}
|
||||||
if save:
|
if save:
|
||||||
archivio.salva(nome, corpo)
|
archivio.salva(nome, corpo, mappa) # #84: la mappa (se densa) va in .npz accanto
|
||||||
return {**corpo, "overlay_png": risultato["overlay_png"], "saved": bool(save)}
|
return {**corpo, "overlay_png": risultato["overlay_png"], "saved": bool(save)}
|
||||||
|
|
||||||
@app.get("/calibrazione", response_class=HTMLResponse)
|
@app.get("/calibrazione", response_class=HTMLResponse)
|
||||||
|
|||||||
@@ -36,6 +36,27 @@ def una_scacchiera(square_px: int = 40, nx: int = 10, ny: int = 7, margin: int =
|
|||||||
return img
|
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)."""
|
||||||
|
yy, xx = np.mgrid[0:h, 0:w]
|
||||||
|
piana = (((xx // square_px) + (yy // square_px)) % 2 * 255).astype(np.uint8)
|
||||||
|
cx, cy = w / 2.0, h / 2.0
|
||||||
|
dx, dy = (xx - cx).astype(np.float32), (yy - cy).astype(np.float32)
|
||||||
|
f = 1 + k * (dx * dx + dy * dy)
|
||||||
|
img = cv2.remap(piana, cx + dx * f, cy + dy * f, cv2.INTER_LINEAR, borderValue=255)
|
||||||
|
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:
|
def _png(img: np.ndarray) -> bytes:
|
||||||
ok, buf = cv2.imencode(".png", img)
|
ok, buf = cv2.imencode(".png", img)
|
||||||
assert ok
|
assert ok
|
||||||
@@ -62,6 +83,29 @@ def client(cartella, tmp_path):
|
|||||||
CAMPI = {"cols": "9", "rows": "6", "square_mm": "2"}
|
CAMPI = {"cols": "9", "rows": "6", "square_mm": "2"}
|
||||||
CHIAVI_C1 = {"source", "px_per_mm", "mm_per_px", "rms_px", "n_corners", "cols", "rows",
|
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"}
|
"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"}
|
||||||
|
CHIAVI_DENSA = CHIAVI_PLANARE | {"residuo_rms_px", "residuo_max_px", "copertura_pct"}
|
||||||
|
SORGENTE = "camera:folder"
|
||||||
|
|
||||||
|
|
||||||
|
@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"
|
NON_IMMAGINE = b"questo non e una immagine"
|
||||||
|
|
||||||
|
|
||||||
@@ -112,7 +156,8 @@ def test_c2_immagine_caricata_senza_salvare(client):
|
|||||||
files={"image": ("s.png", _png(una_scacchiera()), "image/png")})
|
files={"image": ("s.png", _png(una_scacchiera()), "image/png")})
|
||||||
assert r.status_code == 200, r.text
|
assert r.status_code == 200, r.text
|
||||||
corpo = r.json()
|
corpo = r.json()
|
||||||
assert CHIAVI_C1 | {"overlay_png", "saved"} == set(corpo)
|
assert CHIAVI_PLANARE | {"overlay_png", "saved"} == set(corpo)
|
||||||
|
assert corpo["metodo"] == "planare" and corpo["distorsione"] is False # #84: con cols/rows nulla cambia
|
||||||
assert corpo["saved"] is False
|
assert corpo["saved"] is False
|
||||||
assert corpo["px_per_mm"] == pytest.approx(20.0, rel=0.005)
|
assert corpo["px_per_mm"] == pytest.approx(20.0, rel=0.005)
|
||||||
# senza save non si scrive nulla
|
# senza save non si scrive nulla
|
||||||
@@ -129,7 +174,7 @@ def test_c2_c1_c3_ciclo_con_grab_dalla_sorgente(client):
|
|||||||
|
|
||||||
r1 = client.get("/camera/calibration", params={"source": "camera:folder"})
|
r1 = client.get("/camera/calibration", params={"source": "camera:folder"})
|
||||||
assert r1.status_code == 200
|
assert r1.status_code == 200
|
||||||
assert set(r1.json()) == CHIAVI_C1 # nessun overlay nel file salvato
|
assert set(r1.json()) == CHIAVI_PLANARE # nessun overlay nel file salvato
|
||||||
assert r1.json()["px_per_mm"] == pytest.approx(20.0, rel=0.005)
|
assert r1.json()["px_per_mm"] == pytest.approx(20.0, rel=0.005)
|
||||||
assert r1.json()["source"] == "camera:folder"
|
assert r1.json()["source"] == "camera:folder"
|
||||||
file_salvati = list(client.config.glob("*.json"))
|
file_salvati = list(client.config.glob("*.json"))
|
||||||
@@ -180,5 +225,151 @@ def test_c4_pagina_html(client):
|
|||||||
r = client.get("/calibrazione")
|
r = client.get("/calibrazione")
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert r.headers["content-type"].startswith("text/html")
|
assert r.headers["content-type"].startswith("text/html")
|
||||||
assert "Acquisisci e calcola" in r.text
|
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
|
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 70 < 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("s", {"a": 1}, mappa)
|
||||||
|
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.glob("*.tmp*")) # niente temporanei rimasti
|
||||||
|
arch.salva("s", {"a": 2}, mappa) # file riscritto -> cache invalidata
|
||||||
|
assert arch.leggi_mappa("s") is not prima
|
||||||
|
arch.salva("s", {"a": 3}) # salvataggio senza mappa (planare): la vecchia sparisce
|
||||||
|
assert arch.leggi_mappa("s") is None and not list(tmp_path.glob("*.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", "saved"} == 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 corpo["saved"] is False
|
||||||
|
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", "save": "true"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["saved"] is True
|
||||||
|
json_salvati, mappe = list(client_denso.config.glob("*.json")), list(client_denso.config.glob("*.npz"))
|
||||||
|
assert len(json_salvati) == 1 and len(mappe) == 1
|
||||||
|
assert mappe[0].stem == json_salvati[0].stem # stesso nome del JSON
|
||||||
|
saved = client_denso.get("/camera/calibration", params={"source": SORGENTE}).json()
|
||||||
|
assert set(saved) == CHIAVI_DENSA 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_delete_toglie_anche_la_mappa(client_denso):
|
||||||
|
client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2", "save": "true"})
|
||||||
|
assert list(client_denso.config.glob("*.npz"))
|
||||||
|
assert client_denso.delete("/camera/calibration", params={"source": SORGENTE}).status_code == 204
|
||||||
|
assert not list(client_denso.config.glob("*.npz")) and not list(client_denso.config.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):
|
||||||
|
client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2", "save": "true"})
|
||||||
|
assert list(client_denso.config.glob("*.npz"))
|
||||||
|
planare = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "save": "true", **CAMPI},
|
||||||
|
files={"image": ("s.png", _png(una_scacchiera()), "image/png")})
|
||||||
|
assert planare.status_code == 200 and planare.json()["metodo"] == "planare"
|
||||||
|
assert not list(client_denso.config.glob("*.npz")) # niente mappa vecchia che raddrizza a vuoto
|
||||||
|
assert client_denso.post("/camera/grab", json={"source": SORGENTE}).headers["x-raddrizzato"] == "no"
|
||||||
|
|||||||
Reference in New Issue
Block a user