From 7fa5bacdfe6e5b39fca4971c16b7e76cd641e1b4 Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Fri, 23 Oct 2026 23:56:15 +0200 Subject: [PATCH] Acquisitore: calibrazione densa su C2/grab/pagina, test (#84) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB --- scripts/calibrazione_pagina.py | 55 +++++--- scripts/camera_service.py | 47 +++++-- scripts/tests/test_calibrazione.py | 197 ++++++++++++++++++++++++++++- 3 files changed, 265 insertions(+), 34 deletions(-) diff --git a/scripts/calibrazione_pagina.py b/scripts/calibrazione_pagina.py index 33a9409..500ddb2 100644 --- a/scripts/calibrazione_pagina.py +++ b/scripts/calibrazione_pagina.py @@ -3,6 +3,8 @@ 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, /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. """ @@ -41,20 +43,18 @@ PAGINA = """
-
-
-
+

- Gli angoli interni sono quelli dove si toccano quattro quadrati: una scacchiera di 10 x 7 quadrati ha 9 x 6 angoli. - Tenere la scacchiera piana e ferma, ben illuminata.

+ Servono una scacchiera che riempie tutto il campo della camera, piana, ferma e ben illuminata, e il lato di uno scacco. + La griglia si trova da sola; il risultato corregge anche la distorsione dell'ottica.

- +
- +
@@ -64,7 +64,7 @@ PAGINA = """
- + Da camera, salvando si acquisisce e si calcola di nuovo: tenere la scacchiera ferma.
Angoli trovati sulla scacchiera @@ -95,12 +95,19 @@ function numero(v, cifre) { return Number(v).toFixed(cifre); } function righe(d) { return [ ["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"], - ["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"], + ["Variazione di scala nell'area", numero(d.scale_spread_pct, 2) + " %"] + ] : [ + ["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"] - ]; + ]); } function tabella(el, d) { @@ -157,9 +164,19 @@ async function aggiornaStato() { } function parametri() { - return { - cols: $("cols").value, rows: $("rows").value, quadrato: $("quadrato").value - }; + return { 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) { @@ -168,8 +185,6 @@ async function calcola(conFile, salva) { var p = parametri(); var f = new FormData(); f.append("source", sorgente); - f.append("cols", p.cols); - f.append("rows", p.rows); f.append("square_mm", p.quadrato); f.append("save", salva ? "true" : "false"); if (conFile) { @@ -186,11 +201,9 @@ async function calcola(conFile, salva) { ultimo = { conFile: conFile, dati: d }; tabella($("tabella"), d); $("overlay").src = "data:image/png;base64," + d.overlay_png; - $("avviso").textContent = d.scale_spread_pct > 1 - ? "Attenzione: la scala varia di " + numero(d.scale_spread_pct, 2) + " % nell'area: scacchiera inclinata o ottica non telecentrica." - : ""; + $("avviso").textContent = avvisi(d); $("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(); } catch (e) { messaggio("Servizio non raggiungibile.", "errore"); diff --git a/scripts/camera_service.py b/scripts/camera_service.py index ad4e894..3f221e9 100644 --- a/scripts/camera_service.py +++ b/scripts/camera_service.py @@ -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 `grab` (copiato da TieVision-Studio, src/visionsuite/gui/server.py): 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. Calibrazione a scacchiera per sorgente (#83, logica in scripts/calibrazione.py): GET /camera/calibration?source= -> 200 {source, px_per_mm, mm_per_px, rms_px, n_corners, cols, rows, square_mm, image_width, image_height, scale_spread_pct, calibrated_at} | 404 "sorgente non calibrata" - POST /camera/calibrate multipart: source, cols, rows (angoli interni), - square_mm, image (file, opzionale: senza, grab dalla sorgente come - /camera/grab), save=false -> campi di sopra + overlay_png (b64) + saved; + POST /camera/calibrate multipart: source, square_mm, image (file, opzionale: + senza, grab GREZZO dalla sorgente), save=false, cols/rows (opzionali). + #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 - DELETE /camera/calibration?source= -> 204 + DELETE /camera/calibration?source= -> 204 (toglie JSON e mappa) GET /calibrazione -> pagina HTML autonoma (offline) per fare tutto a mano Il risultato salvato sta in un JSON per sorgente in --config-dir (default %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") if trigger not in _TRIGGER: 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) 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") 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) if not ok: 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 @@ -189,9 +209,9 @@ def create_app(folder: Path | str | None = None, @app.post("/camera/calibrate") async def calibrazione_calcola( source: str = Form(...), - cols: int = Form(...), - rows: int = 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), save: bool = Form(False), ): @@ -203,15 +223,22 @@ def create_app(folder: Path | str | None = None, raise HTTPException(422, "l'immagine caricata non è leggibile") else: 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: - 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: raise HTTPException(422, str(e)) corpo = {"source": nome, **{k: v for k, v in risultato.items() if k != "overlay_png"}, "calibrated_at": calibrazione.ora_iso()} 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)} @app.get("/calibrazione", response_class=HTMLResponse) diff --git a/scripts/tests/test_calibrazione.py b/scripts/tests/test_calibrazione.py index 726ec1a..12bcb29 100644 --- a/scripts/tests/test_calibrazione.py +++ b/scripts/tests/test_calibrazione.py @@ -36,6 +36,27 @@ def una_scacchiera(square_px: int = 40, nx: int = 10, ny: int = 7, margin: int = 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: ok, buf = cv2.imencode(".png", img) assert ok @@ -62,6 +83,29 @@ def client(cartella, tmp_path): 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"} +# #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" @@ -112,7 +156,8 @@ def test_c2_immagine_caricata_senza_salvare(client): 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 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["px_per_mm"] == pytest.approx(20.0, rel=0.005) # 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"}) 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()["source"] == "camera:folder" file_salvati = list(client.config.glob("*.json")) @@ -180,5 +225,151 @@ 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 "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 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"