diff --git a/scripts/calibrazione_pagina.py b/scripts/calibrazione_pagina.py index 810d548..740925d 100644 --- a/scripts/calibrazione_pagina.py +++ b/scripts/calibrazione_pagina.py @@ -268,6 +268,22 @@ PAGINA = r""" + +
+

Calibrazione manuale

+

Senza scacchiera: si scrive a mano quanti millimetri vale un pixel. Vale solo come scala, nessuna correzione dell'ottica.

+
+
+
= - px/mm
+
+
+
+ +
+
+
+
+

Calibrazioni salvate

-
@@ -286,6 +302,7 @@ var ultimo = null; // ultimo calcolo di calibrazione, per «Salva» // numero all'italiana (virgola decimale); cifre = decimali fissi, omesso = fino a 4 function fmt(v, cifre) { + if (v === null || v === undefined || v === "") return "-"; // #95: campi null (rms_px, square_mm della manuale) = «-», non 0 var n = Number(v); if (!isFinite(n)) return "-"; var o = cifre === undefined ? { maximumFractionDigits: 4 } : { minimumFractionDigits: cifre, maximumFractionDigits: cifre }; @@ -494,14 +511,14 @@ function mostraInTabCal(url) { } // #92: riquadro «Calibrata»: l'immagine raddrizzata oppure il motivo per cui non c'e' (404/409 del servizio) -function mostraCalibrata(blob, nome) { +function mostraCalibrata(blob, nome, soloScala) { var vecchio = A.urlCal; A.urlCal = URL.createObjectURL(blob); $("immagineCal").src = A.urlCal; if (vecchio) URL.revokeObjectURL(vecchio); $("corniceCal").hidden = false; $("vuotoCal").hidden = true; - $("nomeCalibrata").textContent = nome ? "«" + nome + "»" : ""; + $("nomeCalibrata").textContent = (nome ? "«" + nome + "»" : "") + (soloScala ? " — solo scala, nessuna correzione ottica" : ""); // #95: senza mappa il frame e' grezzo } function messaggioCalibrata(testo) { $("corniceCal").hidden = true; @@ -544,7 +561,7 @@ async function aggiornaCalibrata(lato) { try { nome = decodeURIComponent(r.headers.get("X-Calibrazione") || ""); } catch (e) { /* intestazione non decodificabile: nessun nome */ } var blob = await r.blob(); if (n !== A.seqCal) return; - mostraCalibrata(blob, nome); + mostraCalibrata(blob, nome, r.headers.get("X-Raddrizzato") === "no"); // #95 } catch (e) { if (n === A.seqCal) messaggioCalibrata("Servizio non raggiungibile."); } @@ -962,6 +979,11 @@ $("tabCal").onclick = function () { scegliTab("cal", true); }; // ---- Calibrazione (logica di #83 e #84; #87: lato precompilato, data italiana, conferma in pagina) function righe(d) { + if (d.metodo === "manuale") return [ // #95: solo scala impostata a mano, niente griglia, residui o lato dello scacco + ["Scala", fmt(d.px_per_mm, 4) + " px/mm (" + fmt(d.mm_per_px, 5) + " mm/px)"], + ["Metodo", "manuale (mm/px impostato a mano, nessuna correzione della distorsione)"], + ["Immagine", fmt(d.image_width, 0) + " x " + fmt(d.image_height, 0) + " px"] + ]; return [ ["Scala", fmt(d.px_per_mm, 4) + " px/mm (" + fmt(d.mm_per_px, 5) + " mm/px)"], ["Metodo", d.metodo !== "densa" ? "planare (senza correzione della distorsione)" : "densa (immagine raddrizzata)"], @@ -1018,6 +1040,7 @@ async function aggiornaStato() { var stato = $("stato"); annullaConferma(); $("calSorgente").textContent = sorgente ? $("sorgente").options[$("sorgente").selectedIndex].textContent : "-"; // #91 + statoManuale(); // #95: «Salva» della calibrazione manuale spento senza sorgente if (!sorgente) { stato.textContent = "-"; $("statoSorgente").textContent = "-"; caricaCalibrazioni(); return; } try { var r = await fetch("/camera/calibration?source=" + encodeURIComponent(sorgente)); @@ -1043,7 +1066,7 @@ async function aggiornaStato() { stato.appendChild(t); stato.appendChild(quando); $("statoSorgente").textContent = "calibrata: " + (d.nome || "(senza nome)") + " · " + fmt(d.px_per_mm, 2) + " px/mm"; // #92 - if (d.square_mm) $("quadrato").value = fmtCasella(d.square_mm); // #87: parte dal lato gia' usato + if (Number(d.square_mm) > 0) $("quadrato").value = fmtCasella(d.square_mm); // #87: parte dal lato gia' usato } } catch (e) { stato.textContent = "Servizio non raggiungibile."; @@ -1114,7 +1137,7 @@ function disegnaElenco() { riga2.textContent = [ c.image_width && c.image_height ? fmt(c.image_width, 0) + "×" + fmt(c.image_height, 0) : "-", fmt(c.px_per_mm, 3) + " px/mm", - c.metodo === "densa" ? "densa" : "planare", + c.metodo === "densa" || c.metodo === "manuale" ? c.metodo : "planare", // #95: «manuale» mostrato come tale c.calibrated_at ? dataItaliana(c.calibrated_at) : "-", descriviOrigine(c.origine) ].join(" · "); @@ -1312,6 +1335,60 @@ async function salvaNome() { } } +// ---- #95: calibrazione manuale (mm/px a mano) +var M = { nome: null, timer: null }; // secondo clic su «Sovrascrivi» entro 4 s +function statoManuale() { + var s = $("sorgente").value; + $("salvaManuale").disabled = !s; + $("notaManuale").textContent = s ? "" : "Scegliere una sorgente nella tab Acquisizione: serve per registrare la risoluzione dell'immagine."; +} +function aggiornaManuale() { + var v = leggiNumero($("mmPx").value); + $("pxMmCalc").textContent = v > 0 ? "= " + fmt(1 / v, 4) + " px/mm" : "= - px/mm"; +} +function annullaSovrascriviManuale() { + clearTimeout(M.timer); + M.timer = null; + M.nome = null; + $("salvaManuale").textContent = "Salva"; +} +async function salvaManuale() { + var sorgente = $("sorgente").value; + if (!sorgente) { dire("msgManuale", "Scegliere una sorgente nella tab Acquisizione.", "errore"); return; } + var mm = leggiNumero($("mmPx").value); + if (!(mm >= 0.00001 && mm <= 100)) { dire("msgManuale", "mm/px deve essere un numero positivo (da 0,00001 a 100).", "errore"); return; } + var nome = $("nomeManuale").value.trim(); + if (!nome) { dire("msgManuale", "Scrivere un nome per la calibrazione.", "errore"); return; } + if (nome.length > 60) { dire("msgManuale", "Il nome può avere al massimo 60 caratteri.", "errore"); return; } + var sovrascrivi = M.timer !== null && M.nome === nome.toLowerCase(); // secondo clic sullo stesso nome + annullaSovrascriviManuale(); + $("salvaManuale").disabled = true; + try { + var r = await fetch("/camera/calibrazioni/manuale", { + method: "POST", headers: JSON_H, + body: JSON.stringify({ source: sorgente, nome: nome, mm_per_px: mm, sovrascrivi: sovrascrivi }) // al servizio sempre il numero, mai il testo con la virgola + }); + if (r.status === 409) { + M.nome = nome.toLowerCase(); + $("salvaManuale").textContent = "Sovrascrivi «" + nome + "»? Clic ancora"; + M.timer = setTimeout(annullaSovrascriviManuale, 4000); + dire("msgManuale", await dettaglio(r), "errore"); + return; + } + if (!r.ok) { dire("msgManuale", await dettaglio(r), "errore"); return; } + dire("msgManuale", "Calibrazione «" + nome + "» salvata: per usarla «Carica» o CONFERMA.", "ok"); + caricaCalibrazioni(); + } catch (e) { + dire("msgManuale", "Servizio non raggiungibile.", "errore"); + } finally { + statoManuale(); + } +} +$("mmPx").oninput = aggiornaManuale; +$("salvaManuale").onclick = salvaManuale; +$("nomeManuale").onkeydown = function (e) { if (e.key === "Enter") salvaManuale(); }; +statoManuale(); + $("acquisisci").onclick = calcola; $("salva").onclick = salvaNome; $("nomeCal").onkeydown = function (e) { if (e.key === "Enter") salvaNome(); }; diff --git a/scripts/camera_service.py b/scripts/camera_service.py index b5f74d4..800a065 100644 --- a/scripts/camera_service.py +++ b/scripts/camera_service.py @@ -29,6 +29,10 @@ Calibrazione a scacchiera (#83, logica in scripts/calibrazione.py). calibrated_at, attiva_su}], attiva: nome|null} (dalla piu' recente) POST /camera/calibrazioni json {calcolo_id, nome, sovrascrivi=false} -> 201 metadati. 404 calcolo scaduto, 400 nome non valido, 409 nome gia' usato + POST /camera/calibrazioni/manuale json {source, nome, mm_per_px, sovrascrivi=false} + -> 201 metadati (#95: mm/px impostato a mano, metodo "manuale", nessuna + mappa; non la attiva). 400 mm/px fuori 0.00001..100 o nome non valido, + 409 nome gia' usato; la risoluzione e' quella del frame tenuto o di un grab DELETE /camera/calibrazioni?nome=X -> 204 (toglie anche la mappa e la scollega da ogni sorgente) | 404 PUT /camera/calibrazione-attiva json {source, nome}: legge un frame dalla @@ -84,6 +88,7 @@ from __future__ import annotations import argparse import logging +import math # #95: mm/px finito nella calibrazione manuale import os import re # #92: nome sicuro per le immagini caricate import sys @@ -440,6 +445,35 @@ def create_app(folder: Path | str | None = None, except calibrazione.NomeGiaUsato as e: raise HTTPException(409, str(e)) + @app.post("/camera/calibrazioni/manuale", status_code=201) + def calibrazioni_manuale(corpo: dict = Body(default=None)): + """#95: calibrazione con mm/px impostato a mano (nessuna scacchiera, nessuna mappa); NON la attiva.""" + corpo = corpo if isinstance(corpo, dict) else {} + sorgente = _sorgente(corpo.get("source")) + mm_px = corpo.get("mm_per_px") + if (isinstance(mm_px, bool) or not isinstance(mm_px, (int, float)) + or not math.isfinite(mm_px) or not 0.00001 <= mm_px <= 100): + raise HTTPException(400, "mm/px deve essere un numero positivo") + sovrascrivi = corpo.get("sovrascrivi", False) + if not isinstance(sovrascrivi, bool): + raise HTTPException(400, "«sovrascrivi» deve essere true o false") + try: + nome = calibrazione.pulisci_nome(corpo.get("nome")) + except calibrazione.NomeNonValido as e: + raise HTTPException(400, str(e)) + with lock: + frame = grezzi.get(sorgente) + if frame is None: # nessun frame tenuto: un grab come /camera/grab (404/503/504) + frame = _grab(sorgente, 5000) + alt, larg = frame.shape[:2] # serve al controllo W×H di PUT /camera/calibrazione-attiva + dati = {"px_per_mm": 1.0 / mm_px, "mm_per_px": float(mm_px), "metodo": "manuale", + "image_width": larg, "image_height": alt, "origine": sorgente, + "calibrated_at": calibrazione.ora_iso(), "rms_px": None, "square_mm": None} + try: + return archivio.salva_nome(nome, dati, None, sovrascrivi) + except calibrazione.NomeGiaUsato as e: + raise HTTPException(409, str(e)) + @app.delete("/camera/calibrazioni", status_code=204) def calibrazioni_elimina(nome: str = ""): if not archivio.elimina_nome(nome): @@ -563,7 +597,7 @@ def create_app(folder: Path | str | None = None, @app.post("/camera/anteprima/calibrata") def camera_anteprima_calibrata(corpo: dict = Body(default=None)): - """#92: l'ultimo frame grezzo dell'anteprima, raddrizzato; NON legge un frame nuovo.""" + """#92: l'ultimo frame grezzo dell'anteprima, raddrizzato; NON legge un frame nuovo. #95: senza mappa esce grezzo (X-Raddrizzato: no).""" corpo = corpo if isinstance(corpo, dict) else {} nome = _sorgente(corpo.get("source")) lato = corpo.get("lato_max", 1280) @@ -577,14 +611,22 @@ def create_app(folder: Path | str | None = None, if attiva is None: raise HTTPException(409, "nessuna calibrazione attiva su questa sorgente") mappa = archivio.leggi_mappa(nome) - if mappa is None: # planare: ha solo scala, nessuna mappa da applicare - raise HTTPException(409, f"la calibrazione «{attiva}» non ha una mappa di correzione (planare)") - corretto = calibrazione.raddrizza(frame, mappa) - if corretto is None: # risoluzione diversa da quella calibrata - ch, cw = mappa.shape[:2] + if mappa is None: # #95: senza mappa (manuale o planare) solo scala: frame grezzo, nessuna correzione + dati = archivio.leggi(nome) or {} + cw, ch = dati.get("image_width"), dati.get("image_height") alt, larg = frame.shape[:2] - raise HTTPException(409, f"la calibrazione «{attiva}» è per immagini {cw}×{ch}, " - f"il frame è {larg}×{alt}") + if isinstance(cw, int) and isinstance(ch, int) and (cw, ch) != (larg, alt): + raise HTTPException(409, f"la calibrazione «{attiva}» è per immagini {cw}×{ch}, " + f"il frame è {larg}×{alt}") + corretto, raddrizzato = frame, "no" + else: + corretto = calibrazione.raddrizza(frame, mappa) + if corretto is None: # risoluzione diversa da quella calibrata + ch, cw = mappa.shape[:2] + alt, larg = frame.shape[:2] + raise HTTPException(409, f"la calibrazione «{attiva}» è per immagini {cw}×{ch}, " + f"il frame è {larg}×{alt}") + raddrizzato = "si" alt, larg = corretto.shape[:2] if corretto.dtype != np.uint8: # il JPEG vuole 8 bit corretto = _grigio8(corretto) @@ -596,7 +638,7 @@ def create_app(folder: Path | str | None = None, if not ok: raise HTTPException(500, "codifica JPEG fallita") return Response(buf.tobytes(), media_type="image/jpeg", headers={ - "X-Raddrizzato": "si", "X-Larghezza": str(larg), "X-Altezza": str(alt), + "X-Raddrizzato": raddrizzato, "X-Larghezza": str(larg), "X-Altezza": str(alt), "X-Calibrazione": urllib.parse.quote(attiva, safe="")}) @app.get("/camera/parametri") diff --git a/scripts/tests/test_calibrazione_manuale.py b/scripts/tests/test_calibrazione_manuale.py new file mode 100644 index 0000000..e1b9827 --- /dev/null +++ b/scripts/tests/test_calibrazione_manuale.py @@ -0,0 +1,148 @@ +"""Test di #95: calibrazione manuale (mm/px impostato a mano) in scripts/camera_service.py e nella PAGINA. + +Uso: uv run --python 3.13 --extra vision-worker --extra dev \ + --with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q +Sorgenti = immagini caricate in tmp_path: niente camera vera, niente ProgramData. +""" +import re + +import numpy as np +import pytest + +from test_calibrazione import _png, cartella, client # fixture della calibrazione +from test_immagini_affiancate import _anteprima, _calibrata, _carica +from test_pagina_acquisizione import PAGINA + +__all__ = ["cartella", "client"] # le fixture importate restano usate + +SRC = "banco.png" # 64x48 +ALTRA = "grande.png" # 80x60, risoluzione diversa + + +@pytest.fixture +def sorgenti(client): + assert _carica(client, SRC, np.full((48, 64), 120, np.uint8)).status_code == 201 + assert _carica(client, ALTRA, np.full((60, 80), 120, np.uint8)).status_code == 201 + return client + + +def _manuale(c, nome="Manuale", mm=0.05, source=SRC, **extra): + return c.post("/camera/calibrazioni/manuale", + json={"source": source, "nome": nome, "mm_per_px": mm, **extra}) + + +# ---- servizio + +def test_manuale_201_con_scala_e_risoluzione_del_frame(sorgenti): + r = _manuale(sorgenti, mm=0.05) + assert r.status_code == 201, r.text + m = r.json() + assert m["nome"] == "Manuale" and m["metodo"] == "manuale" + assert m["px_per_mm"] == pytest.approx(20.0) and m["mm_per_px"] == pytest.approx(0.05) + assert (m["image_width"], m["image_height"]) == (64, 48) + assert m["origine"] == SRC and m["square_mm"] is None and m["attiva_su"] == [] # non la attiva + voci = sorgenti.get("/camera/calibrazioni").json()["calibrazioni"] + assert [v["nome"] for v in voci] == ["Manuale"] + + +def test_manuale_usa_il_frame_tenuto_se_c_e(sorgenti): + assert _anteprima(sorgenti, SRC).status_code == 200 # tiene il grezzo 64x48 + r = _manuale(sorgenti) + assert r.status_code == 201 and (r.json()["image_width"], r.json()["image_height"]) == (64, 48) + + +@pytest.mark.parametrize("valore", [0, -1, 0.000001, 100.5, "abc", None, True]) +def test_manuale_mm_non_valido_400(sorgenti, valore): + r = _manuale(sorgenti, mm=valore) + assert r.status_code == 400 + assert "mm/px deve essere un numero positivo" in r.json()["detail"] + + +def test_manuale_senza_source_o_nome_400_e_sorgente_ignota_404(sorgenti): + assert sorgenti.post("/camera/calibrazioni/manuale", json={"nome": "x", "mm_per_px": 0.1}).status_code == 400 + assert _manuale(sorgenti, nome=" ").status_code == 400 + assert _manuale(sorgenti, source="nonesiste.png").status_code == 404 + + +def test_manuale_409_nome_doppio_e_sovrascrivi(sorgenti): + assert _manuale(sorgenti, mm=0.05).status_code == 201 + r = _manuale(sorgenti, mm=0.1) + assert r.status_code == 409 and "esiste già una calibrazione" in r.json()["detail"] + r = _manuale(sorgenti, mm=0.1, sovrascrivi=True) + assert r.status_code == 201 and r.json()["px_per_mm"] == pytest.approx(10.0) + assert _manuale(sorgenti, mm=0.1, sovrascrivi="si").status_code == 400 + + +def test_attiva_manuale_ok_su_stessa_risoluzione_e_legge_px_per_mm(sorgenti): + assert _manuale(sorgenti, mm=0.05).status_code == 201 + r = sorgenti.put("/camera/calibrazione-attiva", json={"source": SRC, "nome": "Manuale"}) + assert r.status_code == 200, r.text + g = sorgenti.get("/camera/calibration", params={"source": SRC}) + assert g.status_code == 200 and g.json()["px_per_mm"] == pytest.approx(20.0) + assert g.json()["nome"] == "Manuale" and g.json()["metodo"] == "manuale" + + +def test_attiva_manuale_409_su_risoluzione_diversa(sorgenti): + assert _manuale(sorgenti).status_code == 201 + r = sorgenti.put("/camera/calibrazione-attiva", json={"source": ALTRA, "nome": "Manuale"}) + assert r.status_code == 409 and "64×48" in r.json()["detail"] + + +def test_anteprima_calibrata_con_manuale_200_grezza(sorgenti): + assert _manuale(sorgenti).status_code == 201 + assert sorgenti.put("/camera/calibrazione-attiva", json={"source": SRC, "nome": "Manuale"}).status_code == 200 + assert _anteprima(sorgenti, SRC).status_code == 200 + r = _calibrata(sorgenti, SRC) + assert r.status_code == 200, r.text + assert r.headers["X-Raddrizzato"] == "no" and r.headers["X-Calibrazione"] == "Manuale" + assert (r.headers["X-Larghezza"], r.headers["X-Altezza"]) == ("64", "48") + assert r.headers["content-type"] == "image/jpeg" + + +def test_anteprima_calibrata_manuale_409_se_il_frame_ha_altra_risoluzione(sorgenti): + assert _manuale(sorgenti).status_code == 201 + assert sorgenti.put("/camera/calibrazione-attiva", json={"source": SRC, "nome": "Manuale"}).status_code == 200 + assert _anteprima(sorgenti, ALTRA).status_code == 200 + # stessa sorgente attiva, ma il frame tenuto cambia (immagine sostituita con altra misura) + assert _carica(sorgenti, SRC, np.full((60, 80), 9, np.uint8)).status_code == 201 + assert _anteprima(sorgenti, SRC).status_code == 200 + r = _calibrata(sorgenti, SRC) + assert r.status_code == 409 and "64×48" in r.json()["detail"] + + +# ---- pagina + +def test_pagina_scheda_manuale_id_e_testi(): + ids = set(re.findall(r'id="([^"]+)"', PAGINA)) + assert {"mmPx", "pxMmCalc", "nomeManuale", "salvaManuale", "msgManuale"} <= ids + tab = PAGINA.split('id="pannelloCal"')[1] + assert tab.index('id="sezioneCalibrazione"') < tab.index('id="sezioneManuale"') < tab.index('id="sezioneSalvate"') + assert "Calibrazione manuale" in PAGINA and "mm per pixel" in PAGINA + assert "salvata: per usarla «Carica» o CONFERMA" in PAGINA + assert "Sovrascrivi «" in PAGINA.split("async function salvaManuale")[1] # stessa conferma del Salva scacchiera + assert "/camera/calibrazioni/manuale" in PAGINA and "alert(" not in PAGINA and "confirm(" not in PAGINA + + +def test_pagina_manuale_senza_sorgente_pulsante_spento_con_nota(): + stato = PAGINA.split("function statoManuale")[1].split("function aggiornaManuale")[0] + assert 'salvaManuale").disabled = !s' in stato and "Scegliere una sorgente" in stato + assert "statoManuale()" in PAGINA.split("async function aggiornaStato")[1].split("async function caricaCalibrazioni")[0] + + +def test_pagina_px_mm_calcolato_a_ogni_tasto(): + assert '$("mmPx").oninput = aggiornaManuale' in PAGINA + assert '"= " + fmt(1 / v, 4) + " px/mm"' in PAGINA + + +def test_pagina_metodo_manuale_e_campi_null(): + assert 'c.metodo === "densa" || c.metodo === "manuale" ? c.metodo : "planare"' in PAGINA + righe = PAGINA.split("function righe")[1].split("function tabella")[0] + assert 'd.metodo === "manuale"' in righe and "Lato dello scacco" in righe # la manuale ha il suo elenco, le altre il solito + assert 'v === null || v === undefined' in PAGINA.split("function fmt")[1].split("function fmtCasella")[0] + stato = PAGINA.split("async function aggiornaStato")[1].split("async function caricaCalibrazioni")[0] + assert "Number(d.square_mm) > 0" in stato + + +def test_pagina_calibrata_senza_raddrizzamento_dice_solo_scala(): + assert "solo scala, nessuna correzione ottica" in PAGINA + assert 'r.headers.get("X-Raddrizzato") === "no"' in PAGINA diff --git a/scripts/tests/test_immagini_affiancate.py b/scripts/tests/test_immagini_affiancate.py index 2aa79c8..2741649 100644 --- a/scripts/tests/test_immagini_affiancate.py +++ b/scripts/tests/test_immagini_affiancate.py @@ -282,10 +282,11 @@ def test_calibrata_risoluzione_diversa_409(client): assert r.json()["detail"] == "la calibrazione «Banco» è per immagini 640×480, il frame è 320×240" -def test_calibrata_planare_senza_mappa_409(client): +def test_calibrata_planare_senza_mappa_200_grezza(client): + # #95: senza mappa (planare) non e' piu' 409: esce il frame grezzo, X-Raddrizzato: no _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)" + assert r.status_code == 200 + assert r.headers["X-Raddrizzato"] == "no" and unquote(r.headers["X-Calibrazione"]) == "Piana"