Compare commits

...

10 Commits

Author SHA1 Message Date
Adriano 661b782bad backend: test camera e modo di analisi nel task, /vision/check (Refs #97)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 18:26:26 +02:00
Adriano 8f9731839a backend: camera e modo di analisi nel task, /vision/check senza salvataggio (Refs #97)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 18:20:14 +02:00
Adriano 552f96bb47 acquisitore: calibrazione manuale con mm/px impostato a mano (Refs #95)
POST /camera/calibrazioni/manuale salva una voce senza mappa (metodo manuale);
anteprima/calibrata senza mappa risponde il frame grezzo (X-Raddrizzato: no);
scheda «Calibrazione manuale» nella tab Calibrazione.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 17:36:45 +02:00
Adriano 71daab184f acquisitore: il frame bloccato e' quello mostrato (Refs #94)
Con «Blocca immagine» la pagina aspetta l'anteprima in volo (max 3 s) e sospende
ciclo e «Scatta» prima di POST /camera/blocca, cosi' la copia del servizio e' l'ultimo
frame visibile nella tab Calibrazione.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 17:31:38 +02:00
Adriano 9beaf2ed67 acquisitore: immagine viva in Calibrazione e «Blocca immagine» per il calcolo (Refs #94)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 17:25:28 +02:00
Adriano 800ec41254 acquisitore: «Attiva calibrazione» con scelta e CONFERMA sotto l'immagine calibrata (Refs #93)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 17:03:16 +02:00
Adriano 9c45a51770 acquisitore: elenco sorgenti in cache, calibrata chiesta solo se serve (Refs #92)
Servizio: la ricerca camere di /camera/sources resta 60 s in cache (lock,
riscaldamento all'avvio, ?aggiorna=true la forza); le immagini caricate si
leggono sempre fresche. Pagina: l'immagine caricata entra nella select senza
rileggere l'elenco, pulsante Aggiorna, nessuna richiesta calibrata se la
sorgente non ha calibrazione attiva o dopo un 409.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 16:41:18 +02:00
Adriano 4a48af2ae3 merge #92 pagina: sorgente in cima, grezza e calibrata affiancate 2026-10-24 16:28:00 +02:00
Adriano b879563738 merge #92 servizio: immagini come sorgenti, anteprima calibrata 2026-10-24 16:28:00 +02:00
Adriano 632fd51050 acquisitore: immagini caricate come sorgenti e anteprima calibrata (Refs #92)
POST/DELETE /camera/immagini (cartella <config>/immagini), elenco sorgenti con le immagini e senza camera:file, apertura per nome di file con loop, frame grezzo tenuto dall'anteprima e POST /camera/anteprima/calibrata che raddrizza senza leggere un frame nuovo. 22 test nuovi.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 16:26:30 +02:00
18 changed files with 1669 additions and 71 deletions
+285 -38
View File
@@ -46,6 +46,8 @@ PAGINA = r"""<!doctype html>
select:focus, input:focus, button:focus-visible { outline: 2px solid var(--accento); outline-offset: 1px; }
input[type=range] { width: 100%; accent-color: var(--accento); }
input[type=checkbox] { accent-color: var(--accento); }
.scelta-cal { margin-top: 8px; align-items: center; } /* #93 */
.scelta-cal select { flex: 1 1 200px; min-width: 0; } /* #93: nessuno scorrimento a 400 px */
.riga { display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-end; }
.nota { font-size: .82rem; color: var(--secondario); margin: 8px 0 0; }
button { padding: 7px 13px; font-size: .95rem; border: 0; border-radius: 6px; background: var(--accento); color: #fff;
@@ -69,7 +71,7 @@ PAGINA = r"""<!doctype html>
.mirino { position: absolute; inset: 0; pointer-events: none; /* #92: classe, vale per i due riquadri */
background: linear-gradient(#fbbf24, #fbbf24) center / 2px 100% no-repeat,
linear-gradient(#fbbf24, #fbbf24) center / 100% 2px no-repeat; opacity: .85; }
#vuoto, #vuotoCal { padding: 30px 16px; color: var(--secondario); text-align: center; }
#vuoto, #vuotoCal, #vuotoCalTab { padding: 30px 16px; color: var(--secondario); text-align: center; }
/* #92: due riquadri affiancati (grezza | calibrata); in colonna sotto i 900 px */
.riquadri { display: grid; grid-template-columns: minmax(0, 1fr); gap: 14px; margin-top: 10px; }
.riquadri.doppio { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
@@ -140,6 +142,7 @@ PAGINA = r"""<!doctype html>
<label for="sorgente">Camera o immagine</label>
<select id="sorgente"></select>
</div>
<button id="aggiornaSorgenti" class="secondario" type="button" title="Cerca di nuovo le camere collegate">Aggiorna</button>
<button id="caricaImmagine" class="secondario" type="button">Carica immagine da file...</button>
<input id="fileImmagine" type="file" accept="image/*" hidden>
<button id="rimuoviImmagine" class="secondario" type="button" hidden>Rimuovi immagine</button>
@@ -155,7 +158,7 @@ PAGINA = r"""<!doctype html>
<button id="avvia">Avvia anteprima</button>
<button id="ferma" class="secondario" disabled>Ferma anteprima</button>
<button id="scatta" class="secondario">Scatta</button>
<label style="margin:0 0 6px"><input id="mostraCal" type="checkbox"> Mostra immagine calibrata</label>
<label style="margin:0 0 6px"><input id="mostraCal" type="checkbox"> Attiva calibrazione</label>
<label style="margin:0 0 6px"><input id="mirinoCasella" type="checkbox"> Mirino centrale</label>
</div>
<div class="riquadri" id="riquadri">
@@ -178,6 +181,12 @@ PAGINA = r"""<!doctype html>
<div id="mirinoCal" class="mirino" hidden></div>
</div>
</div>
<!-- #93: scelta della calibrazione da attivare sulla sorgente, con CONFERMA -->
<div class="riga scelta-cal">
<select id="sceltaCal" aria-label="Calibrazione da attivare" disabled></select>
<button id="confermaCal" type="button" disabled>CONFERMA</button>
</div>
<div id="msgSceltaCal" class="messaggio"></div>
</div>
</div>
<div class="indicatori">
@@ -223,6 +232,18 @@ PAGINA = r"""<!doctype html>
<section class="scheda" id="sezioneCalibrazione">
<h2>Calibrazione</h2>
<div class="stato" style="margin:0 0 10px">Sorgente: <strong id="calSorgente">-</strong> — immagine grezza dalla tab Acquisizione</div>
<!-- #94: immagine viva (stesso frame dell'anteprima) e «Blocca immagine» per calcolare su quella -->
<div class="palco" style="margin-top:0">
<div id="vuotoCalTab">Nessuna immagine: «Scatta» o avviare l'anteprima nella tab Acquisizione</div>
<div class="cornice" id="corniceCalTab" hidden>
<img id="immagineCalTab" alt="Immagine grezza della sorgente">
</div>
</div>
<div class="riga" style="margin-top:8px">
<button id="scattaCal" class="secondario" type="button">Scatta</button>
<label style="margin:0 0 6px"><input id="bloccaImg" type="checkbox"> Blocca immagine</label>
</div>
<div id="msgBlocco" class="messaggio"></div>
<div class="riga" style="margin-top:10px">
<div><label for="quadrato">Lato dello scacco (mm)</label><input id="quadrato" type="text" inputmode="decimal" value="2" size="8"></div>
<button id="acquisisci" title="Acquisisci e calibra">Calcola</button>
@@ -230,7 +251,7 @@ PAGINA = r"""<!doctype html>
<p class="nota">
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.
Il frame è sempre quello grezzo della sorgente; l'anteprima resta sospesa finché questa tab è aperta.</p>
Il frame è sempre quello grezzo della sorgente; l'anteprima continua anche qui e si può bloccare l'immagine per calcolare su quella.</p>
<div id="messaggio" class="messaggio"></div>
<div id="risultato" hidden style="margin-top:10px">
@@ -247,6 +268,22 @@ PAGINA = r"""<!doctype html>
</div>
</section>
<!-- #95: calibrazione manuale, mm per pixel impostato a mano (nessuna scacchiera) -->
<section class="scheda" id="sezioneManuale">
<h2>Calibrazione manuale</h2>
<p class="nota" style="margin-top:0">Senza scacchiera: si scrive a mano quanti millimetri vale un pixel. Vale solo come scala, nessuna correzione dell'ottica.</p>
<div class="riga" style="margin-top:8px">
<div><label for="mmPx">mm per pixel</label><input id="mmPx" type="text" inputmode="decimal" size="10" autocomplete="off"></div>
<div class="nota" id="pxMmCalc" style="margin:0 0 8px">= - px/mm</div>
</div>
<div class="riga" style="margin-top:6px">
<div><label for="nomeManuale">Nome</label><input id="nomeManuale" type="text" maxlength="60" size="22" autocomplete="off"></div>
<button id="salvaManuale">Salva</button>
</div>
<div id="notaManuale" class="nota"></div>
<div id="msgManuale" class="messaggio"></div>
</section>
<section class="scheda" id="sezioneSalvate">
<h2>Calibrazioni salvate</h2>
<div id="stato" class="stato">-</div>
@@ -265,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 };
@@ -312,12 +350,13 @@ async function dettaglio(risposta) {
// ---- Sorgente
// #92: scegli = nome da selezionare (immagine appena caricata); senza, l'ultima scelta ricordata
async function caricaSorgenti(scegli) {
// #92: aggiorna = true forza la ricerca camere del servizio (camera appena collegata); di norma e' in cache
async function caricaSorgenti(scegli, aggiorna) {
var sel = $("sorgente");
var prima = sel.value;
sel.textContent = "";
try {
var r = await fetch("/camera/sources");
var r = await fetch("/camera/sources" + (aggiorna ? "?aggiorna=true" : ""));
var j = await r.json();
(j.sources || []).forEach(function (s) {
var o = document.createElement("option");
@@ -341,6 +380,27 @@ function sorgenteImmagine() {
return sel.selectedIndex >= 0 && sel.options[sel.selectedIndex].dataset.kind === "image";
}
// #92: aggiunge (o aggiorna) la voce dell'immagine {source, label, kind} nella select e la seleziona
function aggiungiSorgente(j) {
var sel = $("sorgente");
var o = null;
for (var i = 0; i < sel.options.length; i++) if (sel.options[i].value === j.source) o = sel.options[i];
if (!o) { o = document.createElement("option"); o.value = j.source; sel.appendChild(o); }
o.textContent = j.label || j.source;
o.dataset.kind = j.kind || "image";
sel.value = j.source;
cambioSorgente(true);
}
// #92: «Aggiorna» = ricerca camere nuova (sul servizio puo' durare ~10 s)
$("aggiornaSorgenti").onclick = async function () {
$("aggiornaSorgenti").disabled = true;
dire("msgSorgente", "Ricerca delle camere in corso...", "");
await caricaSorgenti(undefined, true);
dire("msgSorgente", "Elenco aggiornato.", "ok");
$("aggiornaSorgenti").disabled = false;
};
// #92: «Carica immagine da file...» -> POST /camera/immagini, poi la nuova immagine diventa la sorgente
$("caricaImmagine").onclick = function () { $("fileImmagine").click(); };
$("fileImmagine").onchange = async function () {
@@ -355,7 +415,7 @@ $("fileImmagine").onchange = async function () {
var r = await fetch("/camera/immagini", { method: "POST", body: f });
if (!r.ok) { dire("msgSorgente", await dettaglio(r), "errore"); return; }
var j = await r.json();
await caricaSorgenti(j.source);
aggiungiSorgente(j); // #92: la ricerca camere puo' durare ~10 s: la voce nuova si aggiunge senza rileggere l'elenco
dire("msgSorgente", "Immagine «" + j.source + "» caricata: premere «Scatta» per vederla.", "ok");
} catch (e) {
dire("msgSorgente", "Servizio non raggiungibile.", "errore");
@@ -385,7 +445,10 @@ $("rimuoviImmagine").onclick = async function () {
try {
var r = await fetch("/camera/immagini?nome=" + encodeURIComponent(nome), { method: "DELETE" });
if (!r.ok) { dire("msgSorgente", await dettaglio(r), "errore"); return; }
await caricaSorgenti("");
var sel = $("sorgente"); // #92: voce tolta in locale, senza rileggere l'elenco dal servizio
sel.removeChild(sel.options[sel.selectedIndex]);
if (sel.options.length) sel.selectedIndex = 0;
cambioSorgente(true);
dire("msgSorgente", "Immagine «" + nome + "» rimossa.", "ok");
} catch (e) {
dire("msgSorgente", "Servizio non raggiungibile.", "errore");
@@ -397,8 +460,10 @@ function cambioSorgente(salva) {
if (salva) memoria("tmf.acq.sorgente", $("sorgente").value);
var era = A.attiva;
fermaAnteprima();
if (A.bloccata) rilasciaBlocco(); // #94: casella spenta e DELETE sulla sorgente vecchia (prima di svuotare i riquadri)
azzeraIndicatori();
svuotaRiquadri(); // #92: il frame vecchio non e' della nuova sorgente
A.calAttiva = null; A.bloccoCal = false; // #92: stato calibrazione della nuova sorgente ancora da leggere
annullaRimuovi();
$("rimuoviImmagine").hidden = !sorgenteImmagine();
dire("msgAnteprima", "");
@@ -410,8 +475,8 @@ function cambioSorgente(salva) {
// ---- Anteprima (#87)
// #91: ris = {w, h} dell'ultimo frame; #92: urlCal/seqCal = riquadro calibrata, riprendi = ciclo sospeso dalla tab Calibrazione, lato = ultimo lato_max
var A = { attiva: false, gen: 0, inVolo: false, url: null, urlCal: null, seqCal: 0, riprendi: false, lato: 1280, nitMax: 0, ris: null };
// #91: ris = {w, h} dell'ultimo frame; #92: calAttiva = la sorgente ha una calibrazione attiva (null = non nota), bloccoCal = un 409 ha fermato le richieste calibrata; urlCal/seqCal = riquadro calibrata, bloccata/sorgenteBloccata/urlBlocco/seqBlocco = #94 immagine bloccata per il calcolo (il riquadro della tab Calibrazione non segue piu' il ciclo), lato = ultimo lato_max
var A = { attiva: false, gen: 0, inVolo: false, url: null, urlCal: null, seqCal: 0, bloccata: false, bloccando: 0, sorgenteBloccata: null, urlBlocco: null, seqBlocco: 0, lato: 1280, calAttiva: null, bloccoCal: false, nitMax: 0, ris: null };
function azzeraIndicatori() {
A.nitMax = 0;
@@ -432,20 +497,28 @@ function mostraImmagine(blob) {
var vecchio = A.url;
A.url = URL.createObjectURL(blob);
$("immagine").src = A.url;
if (vecchio) URL.revokeObjectURL(vecchio); // niente memoria che cresce a ogni frame
if (vecchio && vecchio !== A.urlBlocco) URL.revokeObjectURL(vecchio); // niente memoria che cresce a ogni frame; #94: non quello bloccato
$("cornice").hidden = false;
$("vuoto").hidden = true;
if (!A.bloccata) mostraInTabCal(A.url); // #94: lo stesso blob URL va anche nella tab Calibrazione, salvo immagine bloccata
}
// #94: riquadro «immagine viva» della tab Calibrazione (url null = vuoto)
function mostraInTabCal(url) {
$("immagineCalTab").src = url || "";
$("corniceCalTab").hidden = !url;
$("vuotoCalTab").hidden = !!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;
@@ -458,12 +531,19 @@ function svuotaRiquadri() {
A.seqCal++; // una risposta calibrata ancora in volo non vale piu'
$("cornice").hidden = true;
$("vuoto").hidden = false;
mostraInTabCal(null); // #94: svuotato anche il riquadro della tab Calibrazione
messaggioCalibrata("Premere «Avvia anteprima» o «Scatta».");
}
// #92: raddrizza l'ULTIMO frame grezzo tenuto dal servizio (nessuna lettura nuova); chiamata dopo ogni anteprima, dopo Carica/Scollega e alla casella
async function aggiornaCalibrata(lato) {
var sorgente = $("sorgente").value;
if (!$("mostraCal").checked || !sorgente) return;
if (A.calAttiva === false) { // #92: lo stato dice che non c'e' calibrazione: niente richiesta al servizio
A.seqCal++;
messaggioCalibrata("nessuna calibrazione attiva su questa sorgente");
return;
}
if (A.bloccoCal) return; // #92: dopo un 409 (risoluzione diversa, planare) si richiede solo dopo Carica/Scollega o cambio sorgente
var n = ++A.seqCal;
try {
var r = await fetch("/camera/anteprima/calibrata", {
@@ -472,12 +552,16 @@ async function aggiornaCalibrata(lato) {
body: JSON.stringify({ source: sorgente, lato_max: lato || A.lato })
});
if (n !== A.seqCal) return; // risposta superata da una piu' recente o da un cambio sorgente
if (!r.ok) { messaggioCalibrata(await dettaglio(r)); return; } // l'anteprima grezza continua
if (!r.ok) { // l'anteprima grezza continua
if (r.status === 409) A.bloccoCal = true; // #92: inutile richiedere a ogni frame
messaggioCalibrata(await dettaglio(r));
return;
}
var nome = "";
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.");
}
@@ -545,6 +629,8 @@ async function unaAnteprima(lato) {
async function ciclo(g) {
while (A.inVolo) await pausa(50);
while (A.attiva && g === A.gen) {
while (A.bloccando) await pausa(20); // #94: durante «Blocca immagine» nessuna nuova anteprima
if (!A.attiva || g !== A.gen) return;
var t0 = Date.now();
var esito = await unaAnteprima(1280);
if (g !== A.gen) return;
@@ -570,18 +656,75 @@ function fermaAnteprima() {
// «Scatta»: ferma il ciclo e mostra un frame solo, alla risoluzione piena (fino a 4096 px)
async function scatta() {
fermaAnteprima();
$("scatta").disabled = true;
$("scatta").disabled = $("scattaCal").disabled = true; // #94: anche il «Scatta» della tab Calibrazione
dire("msgAnteprima", "Scatto in corso...", "");
while (A.inVolo) await pausa(50);
while (A.bloccando) await pausa(20); // #94: niente scatto mentre si blocca l'immagine
await unaAnteprima(4096);
$("scatta").disabled = false;
$("scattaCal").disabled = A.bloccata; // #94: con l'immagine bloccata resta spento
}
// #94: «Blocca immagine»: il servizio tiene una copia del frame grezzo e «Calcola» usa quella
function sbloccaLocale() { // casella spenta, il riquadro della tab Calibrazione torna a seguire il ciclo
A.bloccata = false;
A.sorgenteBloccata = null;
A.seqBlocco++; // una risposta di blocco ancora in volo non vale piu'
var b = A.urlBlocco;
A.urlBlocco = null;
if (b && b !== A.url) URL.revokeObjectURL(b);
$("bloccaImg").checked = false;
$("scattaCal").disabled = $("scatta").disabled;
mostraInTabCal($("cornice").hidden ? null : A.url);
}
function rilasciaBlocco() { // DELETE idempotente: l'esito non conta
var s = A.sorgenteBloccata;
sbloccaLocale();
dire("msgBlocco", "");
if (s) fetch("/camera/blocca?source=" + encodeURIComponent(s), { method: "DELETE" }).catch(function () { /* niente da fare */ });
}
$("bloccaImg").onchange = async function () {
if (!this.checked) { rilasciaBlocco(); return; }
var sorgente = $("sorgente").value;
if (!sorgente || $("cornice").hidden) {
$("bloccaImg").checked = false;
dire("msgBlocco", "Nessuna immagine da bloccare: «Scatta» o avviare l'anteprima.", "errore");
return;
}
A.bloccata = true; // subito: il riquadro smette di seguire il ciclo
A.sorgenteBloccata = sorgente;
A.urlBlocco = null; // #94: lo si fissa dopo l'attesa, sul frame davvero mostrato
var n = ++A.seqBlocco;
$("scattaCal").disabled = true;
dire("msgBlocco", "Blocco in corso...", "");
A.bloccando++; // #94: il ciclo e «Scatta» non chiedono nuove anteprime finche' la copia non e' fatta
try {
for (var k = 0; A.inVolo && k < 150; k++) await pausa(20); // #94: attende l'anteprima in volo (max 3 s): il frame copiato e' l'ultimo mostrato
if (n !== A.seqBlocco) return; // spenta o cambiata sorgente durante l'attesa: niente copia
if (A.inVolo) { sbloccaLocale(); dire("msgBlocco", "Anteprima ancora in corso: riprovare.", "errore"); return; }
A.urlBlocco = A.url; // #94: il frame mostrato in questo momento e' quello che il servizio copia
mostraInTabCal(A.url);
var r = await fetch("/camera/blocca", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ source: sorgente }) });
if (n !== A.seqBlocco) { // spenta o cambiata sorgente nel frattempo: la copia appena fatta si toglie
if (r.ok) fetch("/camera/blocca?source=" + encodeURIComponent(sorgente), { method: "DELETE" }).catch(function () { /* niente da fare */ });
return;
}
if (!r.ok) { var d = await dettaglio(r); sbloccaLocale(); dire("msgBlocco", d, "errore"); return; }
var b = await r.json();
dire("msgBlocco", "Immagine bloccata " + b.larghezza + "×" + b.altezza + ": «Calcola» usa questa.", "ok");
} catch (e) {
if (n === A.seqBlocco) { sbloccaLocale(); dire("msgBlocco", "Servizio non raggiungibile.", "errore"); }
} finally {
A.bloccando--; // #94: anche su errore o uscita anticipata
}
};
$("avvia").onclick = avviaAnteprima;
$("ferma").onclick = fermaAnteprima;
$("scatta").onclick = scatta;
$("scattaCal").onclick = scatta; // #94
$("azzeraMax").onclick = function () { A.nitMax = 0; $("nitidezzaMax").textContent = "-"; $("barraNitidezza").style.width = "0"; };
// #92: il mirino vale per entrambi i riquadri; la casella «Mostra immagine calibrata» apre il riquadro e raddrizza il frame gia' tenuto
// #92: il mirino vale per entrambi i riquadri; #93: la casella «Attiva calibrazione» apre il riquadro e raddrizza il frame gia' tenuto
$("mirinoCasella").onchange = function () { $("mirino").hidden = $("mirinoCal").hidden = !$("mirinoCasella").checked; };
function mostraRiquadroCal() {
var si = $("mostraCal").checked;
@@ -818,9 +961,7 @@ function scegliTab(nome, salva) {
$("tabCal").tabIndex = cal ? 0 : -1;
$("pannelloAcq").hidden = cal;
$("pannelloCal").hidden = !cal;
// #92: nella tab Calibrazione il ciclo anteprima si sospende; tornando in Acquisizione riprende se era attivo
if (cal && A.attiva) { A.riprendi = true; fermaAnteprima(); }
if (!cal && A.riprendi) { A.riprendi = false; avviaAnteprima(); }
// #94: il ciclo anteprima non si sospende piu' aprendo la tab Calibrazione (come faceva #92)
if (!cal && salva) aggiornaCalibrata(); // il riquadro calibrata riflette la calibrazione attiva di adesso
if (salva) { memoria("tmf.acq.tab", nome); if (cal) caricaCalibrazioni(); } // l'elenco si rilegge entrando
}
@@ -838,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)"],
@@ -894,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));
@@ -901,10 +1048,12 @@ async function aggiornaStato() {
if (r.status === 404) {
stato.textContent = "Sorgente non calibrata.";
$("statoSorgente").textContent = "non calibrata";
A.calAttiva = false; // #92
} else if (!r.ok) {
stato.textContent = await dettaglio(r);
} else {
var d = await r.json();
A.calAttiva = true; // #92
stato.textContent = "";
var titolo = document.createElement("div");
titolo.textContent = "Calibrazione attiva: " + (d.nome ? "«" + d.nome + "»" : "(senza nome)");
@@ -917,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.";
@@ -956,6 +1105,7 @@ function disegnaElenco() {
annullaConferma();
t.textContent = "";
C.celle = [];
disegnaSceltaCal(); // #93: la combo sotto la calibrata segue l'elenco, anche se vuoto
if (!C.elenco.length) {
var vuota = document.createElement("div");
vuota.className = "nota";
@@ -987,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(" · ");
@@ -996,7 +1146,7 @@ function disegnaElenco() {
var bc = bottone("Carica", "", function () { caricaNome(c.nome); });
bc.disabled = !$("sorgente").value;
az.appendChild(bc);
if (attiva) az.appendChild(bottone("Scollega", "secondario", scollega));
if (attiva) az.appendChild(bottone("Scollega", "secondario", function () { scollega("msgElenco"); })); // #93: senza l'evento come parametro
az.appendChild(bottone("Elimina", "secondario", function (e) { eliminaNome(c.nome, e.currentTarget); }));
sc.appendChild(riga1);
sc.appendChild(riga2);
@@ -1011,39 +1161,80 @@ function segnaRisoluzioni() {
(C.celle || []).forEach(function (c) {
c.span.hidden = !(A.ris && c.w > 0 && c.h > 0 && (A.ris.w !== c.w || A.ris.h !== c.h));
});
// #93: stesso avviso nelle voci della combo; si cambia solo il testo, cosi' scelta e menu aperto restano
var sel = $("sceltaCal");
for (var i = 0; i < sel.options.length; i++) if (sel.options[i].value) sel.options[i].textContent = testoVoceCal(sel.options[i].dataset);
}
// #93: testo di una voce della combo: «nome · W×H · px/mm», con «risoluzione diversa» se il frame noto non combacia
function testoVoceCal(d) {
var w = Number(d.w), h = Number(d.h);
var t = d.nome + " · " + (w && h ? fmt(w, 0) + "×" + fmt(h, 0) : "-") + " · " + fmt(Number(d.px), 3) + " px/mm";
if (A.ris && w > 0 && h > 0 && (A.ris.w !== w || A.ris.h !== h)) t += " — risoluzione diversa";
return t;
}
// #93: ridisegna la combo (ogni ricarica dell'elenco); preselezionata la calibrazione attiva, senza sorgente tutto spento
function disegnaSceltaCal() {
var sel = $("sceltaCal");
sel.textContent = "";
var nessuna = document.createElement("option");
nessuna.value = "";
nessuna.textContent = "— nessuna (non calibrata) —";
sel.appendChild(nessuna);
(C.elenco || []).forEach(function (c) {
var o = document.createElement("option");
o.value = c.nome;
o.dataset.nome = c.nome; o.dataset.w = c.image_width || 0; o.dataset.h = c.image_height || 0; o.dataset.px = c.px_per_mm || 0;
o.textContent = testoVoceCal(o.dataset);
sel.appendChild(o);
});
sel.value = C.attiva !== null && C.attiva !== undefined ? C.attiva : "";
if (sel.selectedIndex < 0) sel.selectedIndex = 0;
sel.disabled = $("confermaCal").disabled = !$("sorgente").value;
}
// «Carica»: la calibrazione diventa quella attiva della sorgente. Un errore (es. risoluzione diversa) non ferma l'anteprima.
async function caricaNome(nome) {
async function caricaNome(nome, idMsg) {
idMsg = idMsg || "msgElenco"; // #93: il messaggio puo' stare sotto la calibrata (msgSceltaCal)
var sorgente = $("sorgente").value;
if (!sorgente) { dire("msgElenco", "Scegliere una sorgente nella tab Acquisizione.", "errore"); return; }
dire("msgElenco", "Caricamento di «" + nome + "»...", "");
if (!sorgente) { dire(idMsg, "Scegliere una sorgente nella tab Acquisizione.", "errore"); return; }
dire(idMsg, "Caricamento di «" + nome + "»...", "");
try {
var r = await fetch("/camera/calibrazione-attiva", { method: "PUT", headers: JSON_H, body: JSON.stringify({ source: sorgente, nome: nome }) });
if (sorgente !== $("sorgente").value) return;
if (!r.ok) { dire("msgElenco", await dettaglio(r), "errore"); return; }
dire("msgElenco", "Calibrazione «" + nome + "» caricata sulla sorgente.", "ok");
aggiornaStato();
aggiornaCalibrata(); // #92: il riquadro calibrata usa subito la nuova calibrazione
if (!r.ok) { dire(idMsg, await dettaglio(r), "errore"); disegnaSceltaCal(); return; } // #93: la combo torna sull'attiva
dire(idMsg, "Calibrazione «" + nome + "» caricata sulla sorgente.", "ok");
A.bloccoCal = false; // #92
await aggiornaStato(); // prima lo stato nuovo (rilegge anche l'elenco, #93: schede e combo allineate), poi il riquadro calibrata
aggiornaCalibrata();
} catch (e) {
dire("msgElenco", "Servizio non raggiungibile.", "errore");
dire(idMsg, "Servizio non raggiungibile.", "errore");
disegnaSceltaCal(); // #93
}
}
// «Scollega»: la sorgente torna senza calibrazione, la libreria resta
async function scollega() {
async function scollega(idMsg) {
idMsg = idMsg || "msgElenco"; // #93
var sorgente = $("sorgente").value;
if (!sorgente) return;
try {
var r = await fetch("/camera/calibration?source=" + encodeURIComponent(sorgente), { method: "DELETE" });
dire("msgElenco", r.ok ? "Calibrazione scollegata dalla sorgente (resta fra le salvate)." : await dettaglio(r), r.ok ? "ok" : "errore");
dire(idMsg, r.ok ? "Calibrazione scollegata dalla sorgente (resta fra le salvate)." : await dettaglio(r), r.ok ? "ok" : "errore");
} catch (e) {
dire("msgElenco", "Servizio non raggiungibile.", "errore");
dire(idMsg, "Servizio non raggiungibile.", "errore");
}
aggiornaStato();
aggiornaCalibrata(); // #92: senza calibrazione il riquadro mostra il 409 del servizio
A.bloccoCal = false; // #92
await aggiornaStato(); // #93: rilegge anche l'elenco, quindi la combo torna sull'attiva anche dopo un errore
aggiornaCalibrata(); // #92: senza calibrazione il riquadro dice che non c'e'
}
// #93: CONFERMA sotto la calibrata: voce scelta -> PUT calibrazione-attiva, «nessuna» -> DELETE calibration
$("confermaCal").onclick = function () {
var nome = $("sceltaCal").value;
if (nome) caricaNome(nome, "msgSceltaCal"); else scollega("msgSceltaCal");
};
// «Elimina» di una riga: conferma in pagina con un secondo clic entro 4 s (niente finestre del browser)
function annullaConferma() {
clearTimeout(C.confermaTimer);
@@ -1080,11 +1271,13 @@ async function calcola() {
var f = new FormData();
f.append("square_mm", String(lato)); // al servizio sempre con il punto
f.append("source", sorgente);
fermaAnteprima(); // #92: in questa tab il ciclo e' gia' sospeso; qui si aspetta anche l'eventuale richiesta ancora in volo
var bloccata = A.bloccata; // #94: immagine bloccata = calcolo sulla copia del servizio, l'anteprima puo' continuare
if (bloccata) f.append("bloccata", "true");
else fermaAnteprima(); // #92: si ferma il ciclo; sotto si aspetta anche l'eventuale richiesta ancora in volo
messaggio("Calcolo in corso...", "");
["acquisisci", "salva"].forEach(function (i) { $(i).disabled = true; });
try {
while (A.inVolo) await pausa(50);
while (!bloccata && A.inVolo) await pausa(50);
var r = await fetch("/camera/calibrate", { method: "POST", body: f });
if (!r.ok) { messaggio(await dettaglio(r), "errore"); return; }
var d = await r.json();
@@ -1092,7 +1285,7 @@ async function calcola() {
tabella($("tabella"), d);
$("overlay").src = "data:image/png;base64," + d.overlay_png;
$("avviso").textContent = avvisi(d);
$("origineCalcolo").textContent = "Origine: " + descriviOrigine(d.origine);
$("origineCalcolo").textContent = "Origine: " + (d.bloccata ? "immagine bloccata — " : "") + descriviOrigine(d.origine); // #94
$("risultato").hidden = false;
annullaSovrascrivi();
dire("msgSalva", "");
@@ -1142,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(); };
+207 -8
View File
@@ -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
@@ -68,6 +72,9 @@ Uso (dalla radice del repo):
[--host 127.0.0.1] [--port 8765] [--folder <cartella immagini>] \
[--config-dir <cartella calibrazioni>]
--folder abilita la sorgente finta `camera:folder` (loop sulle immagini).
Immagini caricate (#92): POST/DELETE /camera/immagini, in <config-dir>/immagini; sono sorgenti
a pieno titolo (/camera/sources, grab, anteprima, calibrate). POST /camera/anteprima/calibrata
raddrizza l'ultimo frame grezzo dell'anteprima con la calibrazione attiva, senza leggerne uno nuovo.
Da Docker su Linux: --host 172.17.0.1 (gateway di docker0).
Dipende da: venv di VisionSuite (`uv sync --all-packages` in vendor/visionsuite):
@@ -81,9 +88,13 @@ 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
import threading
import time # #92: scadenza della cache delle camere
import urllib.parse # #92: nome calibrazione nell'header X-Calibrazione
import uuid # #91 id dei calcoli in attesa di nome
from collections import OrderedDict
from contextlib import asynccontextmanager
@@ -96,7 +107,7 @@ from fastapi.concurrency import run_in_threadpool # #83: il grab bloccante fuor
from fastapi.responses import HTMLResponse, Response
from visionsuite.camera.camera_interface import CameraError, CameraGrabError, CameraStateError
from visionsuite.camera.sources import SourceNotFound, list_sources, open_source
from visionsuite.camera.sources import _ESTENSIONI, SourceNotFound, list_sources, open_source # #92: _ESTENSIONI = tipi ammessi per le immagini caricate
# #83: calibrazione e pagina stanno accanto a questo file (che non e' un pacchetto)
sys.path.insert(0, str(Path(__file__).resolve().parent))
@@ -105,6 +116,7 @@ from calibrazione_pagina import PAGINA # noqa: E402
_TRIGGER = ("continuo",)
_MAX_CALCOLI = 8 # #91: calcoli tenuti in memoria in attesa di un nome
_CACHE_CAMERE_S = 60.0 # #92: durata della cache della ricerca camere
_log = logging.getLogger("camera_service") # #87: errori non bloccanti (settaggi)
# #87: i quattro parametri regolabili, nell'ordine di presentazione
_PARAMETRI = ("esposizione", "esposizione_auto", "guadagno", "guadagno_auto")
@@ -148,6 +160,9 @@ def create_app(folder: Path | str | None = None,
archivio = calibrazione.Archivio(config_dir if config_dir is not None
else _config_dir_default()) # #83
aperte: dict = {}
grezzi: dict = {} # #92: sorgente -> ultimo frame pieno GREZZO dell'anteprima (per anteprima/calibrata)
bloccati: dict = {} # #94: sorgente -> copia del frame grezzo pieno «bloccata» per il calcolo (stesso lock di grezzi)
cartella_immagini = archivio.cartella / "immagini" # #92: immagini caricate dalla pagina (creata al bisogno)
lock = threading.Lock() # protegge SOLO i dizionari, mai un'apertura
lock_sorgenti: dict = {} # nome sorgente -> Lock che serializza apertura e grab
calcoli: OrderedDict = OrderedDict() # #91: calcolo_id -> (dati, mappa); gli ultimi _MAX_CALCOLI
@@ -162,8 +177,32 @@ def create_app(folder: Path | str | None = None,
with lock:
return lock_sorgenti.setdefault(nome, threading.Lock())
# #92: la ricerca camere di list_sources dura ~10 s: il risultato (gia' filtrato) resta 60 s in cache
cache_camere: dict = {"voci": None, "t": 0.0}
lock_ricerca = threading.Lock() # due richieste insieme non lanciano due ricerche
def _camere(aggiorna: bool = False) -> list:
with lock_ricerca:
fresca = cache_camere["voci"] is not None and time.monotonic() - cache_camere["t"] < _CACHE_CAMERE_S
if fresca and not aggiorna:
return cache_camere["voci"]
voci = list_sources(None, cameras=True, config=_config())
# camera:file (replay) non e' mai configurato in TMF -> fuori dall'elenco
cache_camere["voci"] = [{"name": v.name, "kind": v.kind, "label": v.label}
for v in voci if v.kind in ("camera", "virtual") and v.name != "camera:file"]
cache_camere["t"] = time.monotonic()
return cache_camere["voci"]
def _prima_ricerca() -> None:
try:
_camere()
except Exception: # noqa: BLE001 - e' solo un riscaldamento: la prima richiesta riprova
_log.exception("prima ricerca camere fallita")
@asynccontextmanager
async def _vita(_app):
# #92: la prima ricerca parte all'avvio, cosi' la prima pagina non aspetta
threading.Thread(target=_prima_ricerca, daemon=True, name="ricerca-camere").start()
yield
with lock: # alla chiusura rilascia le camere aperte
sorgenti = list(aperte.values())
@@ -177,17 +216,69 @@ def create_app(folder: Path | str | None = None,
app = FastAPI(title="TMF camera service", lifespan=_vita)
@app.get("/camera/sources")
def camera_sources():
voci = list_sources(None, cameras=True, config=_config())
return {"sources": [{"name": v.name, "kind": v.kind, "label": v.label}
for v in voci if v.kind in ("camera", "virtual")]}
def camera_sources(aggiorna: bool = False): # #92: aggiorna=true forza la ricerca (camera appena collegata)
elenco = list(_camere(aggiorna))
# #92: in coda le immagini caricate, sempre fresche dalla cartella (costano nulla)
for v in list_sources(cartella_immagini, cameras=False):
elenco.append({"name": v.name, "kind": "image", "label": f"immagine — {v.name}"})
return {"sources": elenco}
def _chiudi(nome: str) -> None:
"""#92: chiude la sorgente aperta e scorda il frame tenuto (immagine sostituita o tolta)."""
with _lock_di(nome):
with lock:
s = aperte.pop(nome, None)
grezzi.pop(nome, None)
bloccati.pop(nome, None) # #94: tolta l'immagine/chiusa la sorgente, anche la copia bloccata
if s is not None:
try:
s.close()
except Exception: # noqa: BLE001 - la chiusura non deve far fallire la rotta
pass
def _nome_immagine(grezzo: str) -> str:
"""#92: solo il nome del file, coi caratteri sicuri di Archivio._file; "" se non ammesso."""
base = Path((grezzo or "").replace("\\", "/")).name
pulito = re.sub(r"[^A-Za-z0-9._-]+", "_", base).strip("._")
radice, punto, estensione = pulito.rpartition(".")
if not punto or not radice or f".{estensione.lower()}" not in _ESTENSIONI:
return ""
return f"{radice[:100]}.{estensione}"
@app.post("/camera/immagini", status_code=201)
async def immagini_carica(image: UploadFile | None = File(None)):
if image is None:
raise HTTPException(400, "manca il file «image»")
nome = _nome_immagine(image.filename or "")
if not nome:
raise HTTPException(400, "estensione non ammessa: " + ", ".join(_ESTENSIONI))
contenuto = await image.read()
# #92: 422 se non e' un'immagine; la verifica sta PRIMA della scrittura, cosi' non resta nulla su disco
if not contenuto or cv2.imdecode(np.frombuffer(contenuto, np.uint8), cv2.IMREAD_UNCHANGED) is None:
raise HTTPException(422, "il file caricato non è leggibile come immagine")
cartella_immagini.mkdir(parents=True, exist_ok=True)
(cartella_immagini / nome).write_bytes(contenuto) # stesso nome = sostituisce
await run_in_threadpool(_chiudi, nome) # se era aperta, si riapre sul file nuovo
return {"source": nome, "label": f"immagine — {nome}", "kind": "image"}
@app.delete("/camera/immagini", status_code=204)
def immagini_elimina(nome: str = ""):
percorso = cartella_immagini / nome
# #92: solo il nome di un file dentro la cartella, niente percorsi
if not nome or Path(nome).name != nome or not percorso.is_file():
raise HTTPException(404, "immagine non trovata")
_chiudi(nome)
percorso.unlink()
return Response(status_code=204)
def _apri(nome: str):
"""#87: sorgente aperta (o da aprire); da chiamare SOLO sotto _lock_di(nome)."""
with lock:
sorgente = aperte.get(nome)
if sorgente is None:
sorgente = open_source(nome, None, config=_config())
sorgente = open_source(nome, cartella_immagini, config=_config()) # #92: un nome di file = immagine caricata
if hasattr(sorgente, "loop"): # #92: l'immagine singola non deve "finire" al secondo grab (anteprima continua)
sorgente.loop = True
# TieVision-Core#341 / #89: free-run, serve la scena di adesso, non il frame vecchio in coda
if hasattr(sorgente, "solo_ultimo_frame"):
try:
@@ -284,6 +375,7 @@ def create_app(folder: Path | str | None = None,
rows: int | None = Form(None),
image: UploadFile | None = File(None),
save: bool = Form(False), # #91: ignorato, il calcolo non salva mai (si salva con nome)
bloccata: bool = Form(False), # #94: true = calcola sulla copia bloccata, senza grab
):
contenuto = await image.read() if image is not None else b""
if contenuto:
@@ -291,6 +383,13 @@ def create_app(folder: Path | str | None = None,
if frame is None:
raise HTTPException(422, "l'immagine caricata non è leggibile")
origine = "file:" + (Path((image.filename or "").replace("\\", "/")).name or "immagine")
elif bloccata: # #94: copia bloccata, nessun grab e nessun lock camera
nome = _sorgente(source)
with lock:
frame = bloccati.get(nome)
if frame is None:
raise HTTPException(409, "nessuna immagine bloccata su questa sorgente")
origine = nome
else:
nome = _sorgente(source)
frame = await run_in_threadpool(_grab, nome, 5000) # come /camera/grab (404/503/504)
@@ -313,7 +412,8 @@ def create_app(folder: Path | str | None = None,
calcoli[calcolo_id] = (corpo, mappa)
while len(calcoli) > _MAX_CALCOLI:
calcoli.popitem(last=False)
return {**corpo, "calcolo_id": calcolo_id, "overlay_png": risultato["overlay_png"]}
return {**corpo, "calcolo_id": calcolo_id, "overlay_png": risultato["overlay_png"],
"bloccata": bool(bloccata and not contenuto)} # #94: solo nella risposta (non nel salvato): true se il frame e' la copia bloccata
# ---- #91: archivio unico di calibrazioni con nome
@@ -345,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):
@@ -414,7 +543,12 @@ def create_app(folder: Path | str | None = None,
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)
def leggi(s): # #92: il frame grezzo si tiene sotto il lock della sorgente
f = s.grab(timeout)
with lock:
grezzi[nome] = f.copy()
return f
frame = _con_sorgente(nome, leggi)
raddrizzato = False
if not grezzo: # come /camera/grab
mappa = archivio.leggi_mappa(nome)
@@ -442,6 +576,71 @@ def create_app(folder: Path | str | None = None,
"X-Nitidezza": f"{nitidezza:.1f}", "X-Saturati-Pct": f"{saturati:.2f}",
"X-Media": f"{media:.1f}"})
@app.post("/camera/blocca")
def camera_blocca(corpo: dict = Body(default=None)):
"""#94: copia l'ultimo frame grezzo della sorgente; /camera/calibrate con bloccata=true calcola su quella."""
corpo = corpo if isinstance(corpo, dict) else {}
nome = _sorgente(corpo.get("source"))
with lock:
frame = grezzi.get(nome)
if frame is None:
raise HTTPException(404, "nessun frame: avviare l'anteprima o scattare")
bloccati[nome] = frame.copy()
alt, larg = frame.shape[:2]
return {"source": nome, "larghezza": larg, "altezza": alt}
@app.delete("/camera/blocca", status_code=204)
def camera_sblocca(source: str = ""):
with lock: # #94: idempotente, 204 anche se non c'era nulla
bloccati.pop(source.strip(), None)
return Response(status_code=204)
@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. #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)
if isinstance(lato, bool) or not isinstance(lato, int) or not 320 <= lato <= 4096:
raise HTTPException(400, "«lato_max» deve essere un intero fra 320 e 4096")
with lock:
frame = grezzi.get(nome)
if frame is None:
raise HTTPException(404, "nessun frame: avviare l'anteprima o scattare")
attiva = archivio.attiva_di(nome)
if attiva is None:
raise HTTPException(409, "nessuna calibrazione attiva su questa sorgente")
mappa = archivio.leggi_mappa(nome)
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]
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)
scala = lato / max(alt, larg)
if scala < 1.0: # mai ingrandita
corretto = cv2.resize(corretto, (max(1, round(larg * scala)), max(1, round(alt * scala))),
interpolation=cv2.INTER_AREA)
ok, buf = cv2.imencode(".jpg", corretto, [cv2.IMWRITE_JPEG_QUALITY, 85])
if not ok:
raise HTTPException(500, "codifica JPEG fallita")
return Response(buf.tobytes(), media_type="image/jpeg", headers={
"X-Raddrizzato": raddrizzato, "X-Larghezza": str(larg), "X-Altezza": str(alt),
"X-Calibrazione": urllib.parse.quote(attiva, safe="")})
@app.get("/camera/parametri")
def parametri_leggi(source: str = ""):
nome = _sorgente(source)
+156
View File
@@ -0,0 +1,156 @@
"""Test di #94: «Blocca immagine» (scripts/camera_service.py) e riquadro vivo nella tab Calibrazione (PAGINA).
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
Sorgente finta che conta i grab: niente camera vera, niente ProgramData.
"""
import re
import numpy as np
import pytest
from test_calibrazione import _png, cartella, client, una_scacchiera # fixture e helper della calibrazione
from test_immagini_affiancate import _anteprima, _carica, _modulo
from test_pagina_acquisizione import PAGINA
__all__ = ["cartella", "client"] # le fixture importate restano usate
FINTA = "finta"
CAMPI = {"cols": "9", "rows": "6", "square_mm": "2"}
@pytest.fixture
def finta(client, monkeypatch):
"""Sorgente «finta»: il primo grab da' la scacchiera, i successivi un frame bianco (non calibrabile)."""
letture = []
scacchiera = una_scacchiera()
bianco = np.full(scacchiera.shape, 255, np.uint8)
class Finta:
def grab(self, timeout=1000):
letture.append(1)
return scacchiera.copy() if len(letture) == 1 else bianco.copy()
def close(self):
pass
monkeypatch.setitem(_modulo(client), "open_source", lambda *a, **k: Finta())
client.letture = letture
return client
def _blocca(c, nome=FINTA):
return c.post("/camera/blocca", json={"source": nome})
def _calcola(c, **extra):
return c.post("/camera/calibrate", data={"source": FINTA, **CAMPI, **extra})
# ---- servizio
def test_blocca_senza_frame_404(finta):
r = _blocca(finta)
assert r.status_code == 404 and r.json()["detail"] == "nessun frame: avviare l'anteprima o scattare"
def test_blocca_senza_source_400(finta):
assert finta.post("/camera/blocca", json={}).status_code == 400
assert finta.post("/camera/blocca").status_code == 400
def test_anteprima_poi_blocca_200_con_misure(finta):
assert _anteprima(finta, FINTA).status_code == 200
r = _blocca(finta)
alt, larg = una_scacchiera().shape[:2]
assert r.status_code == 200 and r.json() == {"source": FINTA, "larghezza": larg, "altezza": alt}
def test_calibrate_bloccata_usa_la_copia_senza_grab(finta):
assert _anteprima(finta, FINTA).status_code == 200 # frame 1: la scacchiera
assert _blocca(finta).status_code == 200
assert _anteprima(finta, FINTA).status_code == 200 # frame 2: bianco, il grezzo cambia ma la copia no
prima = len(finta.letture)
r = _calcola(finta, bloccata="true")
assert r.status_code == 200, r.text
assert r.json()["bloccata"] is True and r.json()["origine"] == FINTA
assert len(finta.letture) == prima # nessun grab per il calcolo
# senza il blocco il calcolo legge un frame nuovo (bianco): non calibrabile
assert _calcola(finta).status_code == 422
assert len(finta.letture) == prima + 1
def test_calibrate_normale_dice_bloccata_false(client):
r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI})
assert r.status_code == 200, r.text
assert r.json()["bloccata"] is False
def test_calibrate_bloccata_senza_copia_409(finta):
assert _anteprima(finta, FINTA).status_code == 200 # il grezzo c'e', ma non e' bloccato
r = _calcola(finta, bloccata="true")
assert r.status_code == 409 and r.json()["detail"] == "nessuna immagine bloccata su questa sorgente"
def test_sblocca_204_idempotente_e_toglie_la_copia(finta):
assert finta.delete("/camera/blocca", params={"source": FINTA}).status_code == 204 # niente da togliere
assert _anteprima(finta, FINTA).status_code == 200
assert _blocca(finta).status_code == 200
assert finta.delete("/camera/blocca", params={"source": FINTA}).status_code == 204
assert _calcola(finta, bloccata="true").status_code == 409
assert finta.delete("/camera/blocca", params={"source": FINTA}).status_code == 204
def test_rimuovere_l_immagine_toglie_la_copia_bloccata(client):
assert _carica(client, "foto.png", una_scacchiera()).status_code == 201
assert _anteprima(client, "foto.png").status_code == 200
assert _blocca(client, "foto.png").status_code == 200
assert client.delete("/camera/immagini", params={"nome": "foto.png"}).status_code == 204
assert _carica(client, "foto.png", una_scacchiera()).status_code == 201
r = client.post("/camera/calibrate", data={"source": "foto.png", "bloccata": "true", **CAMPI})
assert r.status_code == 409
# ---- pagina (contenuto statico)
def test_pagina_riquadro_e_casella_nella_tab_calibrazione():
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
assert {"immagineCalTab", "vuotoCalTab", "scattaCal", "bloccaImg", "msgBlocco"} <= ids
assert "Nessuna immagine: «Scatta» o avviare l'anteprima nella tab Acquisizione" in PAGINA
assert "Blocca immagine" in PAGINA
tab = PAGINA.split('id="pannelloCal"')[1].split('id="sezioneSalvate"')[0]
assert "immagineCalTab" in tab and "bloccaImg" in tab # nella sezione Calibrazione, non altrove
def test_pagina_scegli_tab_non_ferma_piu_il_ciclo():
assert "A.riprendi" not in PAGINA
scegli = PAGINA.split("function scegliTab")[1].split("$(\"tabAcq\").onclick")[0]
assert "fermaAnteprima" not in scegli and "avviaAnteprima" not in scegli
def test_pagina_calcola_manda_bloccata_e_non_ferma_il_ciclo():
calcola = PAGINA.split("async function calcola")[1].split("async function salvaNome")[0]
assert 'f.append("bloccata", "true")' in calcola
assert "if (bloccata)" in calcola and "else fermaAnteprima();" in calcola
assert "immagine bloccata" in calcola
assert "l'anteprima resta sospesa" not in PAGINA and "si può bloccare l'immagine" in PAGINA
def test_pagina_blocco_rotte_e_cambio_sorgente():
assert '"/camera/blocca"' in PAGINA and '"/camera/blocca?source="' in PAGINA
assert "method: \"DELETE\"" in PAGINA
cambio = PAGINA.split("function cambioSorgente")[1].split("// ---- Anteprima")[0]
assert "rilasciaBlocco()" in cambio
assert cambio.index("rilasciaBlocco()") < cambio.index("svuotaRiquadri()")
def test_pagina_blocco_attende_l_anteprima_in_volo():
# #94: il frame bloccato e' quello mostrato: flag A.bloccando, attesa di A.inVolo (20 ms, max 3 s), poi POST, flag tolto in finally
blocco = PAGINA.split('$("bloccaImg").onchange')[1].split('$("avvia").onclick')[0]
assert "A.bloccando++" in blocco and "A.bloccando--" in blocco and "finally" in blocco
assert "k < 150" in blocco and "pausa(20)" in blocco
assert blocco.index("A.inVolo") < blocco.index('fetch("/camera/blocca"')
ciclo = PAGINA.split("async function ciclo")[1].split("function avviaAnteprima")[0]
assert "while (A.bloccando)" in ciclo and ciclo.index("A.bloccando") < ciclo.index("unaAnteprima(1280)")
scatta = PAGINA.split("async function scatta")[1].split("// #94: «Blocca immagine»")[0]
assert "while (A.bloccando)" in scatta and scatta.index("A.bloccando") < scatta.index("unaAnteprima(4096)")
+2 -2
View File
@@ -169,7 +169,7 @@ 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_PLANARE | {"overlay_png", "calcolo_id"} == set(corpo) # #91: niente "saved", niente "source"
assert CHIAVI_PLANARE | {"overlay_png", "calcolo_id", "bloccata"} == set(corpo) # #94: + bloccata; #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)
@@ -337,7 +337,7 @@ 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 CHIAVI_DENSA | {"overlay_png", "calcolo_id", "bloccata"} == 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"])
+148
View File
@@ -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
+292
View File
@@ -0,0 +1,292 @@
"""Test di #92: immagini caricate come sorgenti e anteprima calibrata (scripts/camera_service.py).
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
Solo tmp_path: niente ProgramData, niente camera vera. Le immagini le scrive il test.
"""
from urllib.parse import quote, unquote
import cv2
import numpy as np
import pytest
from test_calibrazione import ( # fixture e helper della calibrazione (#83/#84)
CAMPI, _png, carica_su, cartella, client, salva_con_nome, una_scacchiera,
una_scacchiera_densa,
)
__all__ = ["cartella", "client"] # le fixture importate restano usate
DENSA = "densa.png"
PIANA = "piana.png"
def _modulo(c) -> dict:
"""Il dict globale di camera_service (per sostituire open_source con una spia)."""
return next(r.endpoint for r in c.app.routes if r.path == "/camera/grab").__globals__
def _carica(c, nome: str, img: np.ndarray, **extra):
"""POST /camera/immagini con un'immagine codificata PNG (o il contenuto dato)."""
return c.post("/camera/immagini", files={"image": (nome, _png(img), "image/png")}, **extra)
def _densa(**kw) -> np.ndarray:
return una_scacchiera_densa(k=1e-7, **kw)
def _calibra_e_attiva(c, nome_immagine: str, nome_calibrazione: str, **campi):
"""Calcola dalla sorgente, salva con nome e la rende attiva su quella sorgente."""
r = c.post("/camera/calibrate", data={"source": nome_immagine, "square_mm": "2", **campi})
assert r.status_code == 200, r.text
assert salva_con_nome(c, r.json(), nome_calibrazione).status_code == 201
assert carica_su(c, nome_immagine, nome_calibrazione).status_code == 200
def _anteprima(c, nome: str, **extra):
return c.post("/camera/anteprima", json={"source": nome, "grezzo": True, **extra})
def _calibrata(c, nome: str, **extra):
return c.post("/camera/anteprima/calibrata", json={"source": nome, **extra})
# ---- POST /camera/immagini
def test_carica_201_salva_in_cartella_immagini(client):
r = _carica(client, "calibration_135_1.bmp", una_scacchiera())
assert r.status_code == 201, r.text
assert r.json() == {"source": "calibration_135_1.bmp",
"label": "immagine — calibration_135_1.bmp", "kind": "image"}
assert (client.config / "immagini" / "calibration_135_1.bmp").is_file()
def test_carica_ripulisce_il_nome_e_toglie_il_percorso(client):
r = _carica(client, "C:\\foto\\ban co è1.PNG", una_scacchiera())
assert r.status_code == 201, r.text
assert r.json()["source"] == "ban_co_1.PNG" # niente percorso, caratteri sicuri, estensione intatta
assert (client.config / "immagini" / "ban_co_1.PNG").is_file()
def test_carica_stesso_nome_sostituisce(client):
_carica(client, "a.png", np.full((48, 64), 10, np.uint8))
assert _carica(client, "a.png", np.full((30, 40), 200, np.uint8)).status_code == 201
assert [f.name for f in (client.config / "immagini").iterdir()] == ["a.png"]
img = cv2.imread(str(client.config / "immagini" / "a.png"), cv2.IMREAD_UNCHANGED)
assert img.shape[:2] == (30, 40)
def test_carica_senza_file_400(client):
assert client.post("/camera/immagini").status_code == 400
def test_carica_estensione_non_ammessa_400(client):
r = client.post("/camera/immagini", files={"image": ("note.txt", b"ciao", "text/plain")})
assert r.status_code == 400
assert not (client.config / "immagini").exists() or not list((client.config / "immagini").iterdir())
def test_carica_file_illeggibile_422_e_niente_su_disco(client):
r = client.post("/camera/immagini", files={"image": ("rotta.png", b"non e una immagine", "image/png")})
assert r.status_code == 422
assert not (client.config / "immagini").exists() or not list((client.config / "immagini").iterdir())
# ---- GET /camera/sources
def test_sources_con_immagini_e_senza_camera_file(client):
_carica(client, "b.png", una_scacchiera())
_carica(client, "a.png", una_scacchiera())
voci = client.get("/camera/sources").json()["sources"]
nomi = [v["name"] for v in voci]
assert "camera:file" not in nomi
assert "camera:folder" in nomi
assert nomi[-2:] == ["a.png", "b.png"] # le immagini stanno in coda
assert voci[-1] == {"name": "b.png", "kind": "image", "label": "immagine — b.png"}
assert nomi.index("camera:folder") < nomi.index("a.png")
def test_sources_camere_in_cache_e_aggiorna(client):
# #92: la ricerca camere (~10 s) si fa una volta e resta 60 s; aggiorna=true la rilancia; le immagini sono sempre fresche
g = _modulo(client)
originale = g["list_sources"]
chiamate = []
def finto(cartella, cameras=True, **kw):
if cameras:
chiamate.append(1)
return originale(cartella, cameras=cameras, **kw)
g["list_sources"] = finto
try:
client.get("/camera/sources") # se la ricerca d'avvio e' ancora in corso aspetta il suo lock
base = len(chiamate)
_carica(client, "nuova.png", una_scacchiera())
voci = client.get("/camera/sources").json()["sources"]
client.get("/camera/sources")
assert len(chiamate) == base # seconda e terza chiamata: nessuna nuova ricerca
assert voci[-1]["name"] == "nuova.png" # ma l'immagine appena caricata c'e'
client.get("/camera/sources", params={"aggiorna": "true"})
assert len(chiamate) == base + 1 # aggiorna=true la rilancia
client.get("/camera/sources")
assert len(chiamate) == base + 1 # e il risultato nuovo torna in cache
finally:
g["list_sources"] = originale
def test_sources_senza_immagini_non_crea_la_cartella(client):
voci = client.get("/camera/sources").json()["sources"]
assert all(v["kind"] != "image" for v in voci)
# ---- grab / anteprima / calibrate su un'immagine caricata
def test_grab_anteprima_e_grab_ripetuti_su_immagine(client):
_carica(client, "foto.png", una_scacchiera())
for _ in range(3): # l'anteprima continua non deve esaurire un'immagine sola
r = client.post("/camera/grab", json={"source": "foto.png"})
assert r.status_code == 200 and r.headers["content-type"] == "image/png"
for _ in range(3):
r = _anteprima(client, "foto.png")
assert r.status_code == 200 and r.headers["content-type"] == "image/jpeg"
assert (r.headers["X-Larghezza"], r.headers["X-Altezza"]) == ("520", "400")
def test_grab_immagine_inesistente_404(client):
assert client.post("/camera/grab", json={"source": "non_c_e.png"}).status_code == 404
def test_calibrate_su_immagine_origine_e_il_nome(client):
_carica(client, "calibration_135_1.bmp", una_scacchiera())
r = client.post("/camera/calibrate", data={"source": "calibration_135_1.bmp", **CAMPI})
assert r.status_code == 200, r.text
assert r.json()["origine"] == "calibration_135_1.bmp"
# ---- DELETE /camera/immagini
def test_elimina_204_poi_404_e_sparisce_dalle_sorgenti(client):
_carica(client, "foto.png", una_scacchiera())
assert client.delete("/camera/immagini", params={"nome": "foto.png"}).status_code == 204
assert not (client.config / "immagini" / "foto.png").exists()
assert client.delete("/camera/immagini", params={"nome": "foto.png"}).status_code == 404
assert "foto.png" not in [v["name"] for v in client.get("/camera/sources").json()["sources"]]
assert client.post("/camera/grab", json={"source": "foto.png"}).status_code == 404
def test_elimina_nome_con_percorso_404(client):
_carica(client, "foto.png", una_scacchiera())
assert client.delete("/camera/immagini", params={"nome": "../immagini/foto.png"}).status_code == 404
assert client.delete("/camera/immagini").status_code == 404
assert (client.config / "immagini" / "foto.png").is_file()
def test_elimina_chiude_la_sorgente_aperta(client, monkeypatch):
chiuse = []
modulo = _modulo(client)
reale = modulo["open_source"]
def spia(*a, **k):
s = reale(*a, **k)
chiusura = s.close
s.close = lambda: (chiuse.append(a[0]), chiusura())[1]
return s
monkeypatch.setitem(modulo, "open_source", spia)
_carica(client, "foto.png", una_scacchiera())
assert client.post("/camera/grab", json={"source": "foto.png"}).status_code == 200
assert client.delete("/camera/immagini", params={"nome": "foto.png"}).status_code == 204
assert chiuse == ["foto.png"]
# ---- POST /camera/anteprima/calibrata
def test_calibrata_senza_frame_404(client):
_carica(client, DENSA, _densa())
r = _calibrata(client, DENSA)
assert r.status_code == 404 and r.json()["detail"] == "nessun frame: avviare l'anteprima o scattare"
def test_calibrata_senza_attiva_409(client):
_carica(client, DENSA, _densa())
assert _anteprima(client, DENSA).status_code == 200
r = _calibrata(client, DENSA)
assert r.status_code == 409 and r.json()["detail"] == "nessuna calibrazione attiva su questa sorgente"
def test_calibrata_validazione_ingresso(client):
assert client.post("/camera/anteprima/calibrata", json={}).status_code == 400
assert _calibrata(client, DENSA, lato_max=100).status_code == 400
assert _calibrata(client, DENSA, lato_max=True).status_code == 400
def test_calibrata_200_densa_senza_nuova_lettura(client, monkeypatch):
modulo = _modulo(client)
reale = modulo["open_source"]
letture = []
def conta(*a, **k):
s = reale(*a, **k)
originale = s.grab
s.grab = lambda t=1000: (letture.append(1), originale(t))[1]
return s
monkeypatch.setitem(modulo, "open_source", conta)
_carica(client, DENSA, _densa())
_calibra_e_attiva(client, DENSA, "Banco àè 1")
assert _anteprima(client, DENSA).status_code == 200
prima = len(letture)
for _ in range(3):
r = _calibrata(client, DENSA)
assert r.status_code == 200, r.text
assert r.headers["content-type"] == "image/jpeg"
assert r.headers["X-Raddrizzato"] == "si"
assert (r.headers["X-Larghezza"], r.headers["X-Altezza"]) == ("640", "480")
assert unquote(r.headers["X-Calibrazione"]) == "Banco àè 1"
assert r.headers["X-Calibrazione"] == quote("Banco àè 1", safe="") # percent-encoded UTF-8
img = cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_UNCHANGED)
assert img.shape[:2] == (480, 640)
assert len(letture) == prima # la calibrata non legge mai un frame nuovo
def test_calibrata_ridotta_a_lato_max(client):
_carica(client, DENSA, _densa())
_calibra_e_attiva(client, DENSA, "Banco")
assert _anteprima(client, DENSA).status_code == 200
r = _calibrata(client, DENSA, lato_max=320)
assert r.status_code == 200
img = cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_UNCHANGED)
assert max(img.shape[:2]) == 320
assert (r.headers["X-Larghezza"], r.headers["X-Altezza"]) == ("640", "480") # misure del frame pieno
def test_anteprima_grezza_e_calibrata_usano_lo_stesso_frame_grezzo(client):
# anche con anteprima NON grezza la copia tenuta e' quella prima di raddrizzare
_carica(client, DENSA, _densa())
_calibra_e_attiva(client, DENSA, "Banco")
r = client.post("/camera/anteprima", json={"source": DENSA}) # calibrata: raddrizzata
assert r.status_code == 200 and r.headers["X-Raddrizzato"] == "si"
r = _calibrata(client, DENSA)
assert r.status_code == 200 and r.headers["X-Raddrizzato"] == "si" # raddrizzata una volta sola
def test_calibrata_risoluzione_diversa_409(client):
_carica(client, DENSA, _densa())
_calibra_e_attiva(client, DENSA, "Banco")
# stessa sorgente, immagine sostituita con un'altra risoluzione: la calibrazione resta attiva
assert _carica(client, DENSA, _densa(w=320, h=240)).status_code == 201
assert _calibrata(client, DENSA).status_code == 404 # sostituire l'immagine scorda il frame vecchio
assert _anteprima(client, DENSA).status_code == 200
r = _calibrata(client, DENSA)
assert r.status_code == 409
assert r.json()["detail"] == "la calibrazione «Banco» è per immagini 640×480, il frame è 320×240"
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 == 200
assert r.headers["X-Raddrizzato"] == "no" and unquote(r.headers["X-Calibrazione"]) == "Piana"
+1 -1
View File
@@ -42,7 +42,7 @@ def test_id_principali():
def test_sezioni_e_testi_italiani():
for testo in ("Sorgente", "Anteprima", "Parametri camera", "Calibrazione",
"Salva come predefiniti", "Carica immagine da file...", "Questa sorgente non ha parametri regolabili",
"Mostra immagine calibrata"): # #92
"Attiva calibrazione"): # #92 #93
assert testo in PAGINA, testo
assert "it-IT" in PAGINA # numeri con la virgola
assert "Choose File" not in PAGINA
+35 -6
View File
@@ -30,7 +30,7 @@ def test_sorgente_in_cima_poi_riquadri():
assert i_acq < i_sorg < i_ant < i_riq < PAGINA.index('id="sezioneParametri"') < i_cal
assert PAGINA.index('id="sezioneCalibrazione"') > i_cal
# barra: casella calibrata e mirino; la vecchia casella «Immagine grezza» non c'e' piu'
assert 'id="mostraCal"' in PAGINA and "Mostra immagine calibrata" in PAGINA
assert 'id="mostraCal"' in PAGINA and "Attiva calibrazione" in PAGINA
assert 'id="grezzo"' not in PAGINA and "Immagine grezza (senza correzione)" not in PAGINA
# riquadri: «Immagine» sempre visibile, «Calibrata» nascosta finche' la casella e' spenta
assert re.search(r'<div class="riquadro" id="riquadroCal" hidden>', PAGINA)
@@ -98,12 +98,11 @@ def test_pagina_stretta():
assert "Lettura dei parametri..." in PAGINA
def test_ciclo_sospeso_nella_tab_calibrazione():
# #92: entrando in Calibrazione il ciclo si ferma e poi riprende; Calcola aspetta le richieste in volo
assert "A.riprendi = true; fermaAnteprima();" in PAGINA
assert "if (!cal && A.riprendi)" in PAGINA
def test_ciclo_non_sospeso_nella_tab_calibrazione():
# #94: il ciclo non si ferma piu' entrando in Calibrazione (prima #92: A.riprendi); Calcola senza blocco aspetta le richieste in volo
assert "A.riprendi" not in PAGINA
calcola = PAGINA.split("async function calcola")[1].split("async function salvaNome")[0]
assert "fermaAnteprima();" in calcola and "while (A.inVolo)" in calcola
assert "fermaAnteprima();" in calcola and "A.inVolo) await pausa(50)" in calcola
def test_calibrata_dopo_anteprima_e_dopo_carica():
@@ -112,3 +111,33 @@ def test_calibrata_dopo_anteprima_e_dopo_carica():
assert PAGINA.count("aggiornaCalibrata();") >= 3
assert "messaggioCalibrata(await dettaglio(r))" in PAGINA
assert 'decodeURIComponent(r.headers.get("X-Calibrazione")' in PAGINA
def test_elenco_sorgenti_senza_attese_e_calibrata_solo_se_serve():
# #92: la voce dell'immagine caricata si aggiunge in pagina (niente rilettura dell'elenco); «Aggiorna» forza la ricerca camere
assert "aggiungiSorgente(j);" in PAGINA and "caricaSorgenti(j.source)" not in PAGINA
assert 'id="aggiornaSorgenti"' in PAGINA and ">Aggiorna</button>" in PAGINA
assert '"?aggiorna=true"' in PAGINA
# nessuna calibrazione attiva: il riquadro lo dice senza chiamare il servizio; dopo un 409 non si richiede piu'
assert "if (A.calAttiva === false)" in PAGINA
assert "nessuna calibrazione attiva su questa sorgente" in PAGINA
assert "if (r.status === 409) A.bloccoCal = true;" in PAGINA and "if (A.bloccoCal) return;" in PAGINA
assert PAGINA.count("A.bloccoCal = false;") >= 3 # cambio sorgente, Carica, Scollega
def test_attiva_calibrazione_con_scelta_e_conferma():
# #93: sotto la calibrata una combo e CONFERMA; la casella cambia nome
for i in ('id="sceltaCal"', 'id="confermaCal"', 'id="msgSceltaCal"'):
assert i in PAGINA, i
assert "Attiva calibrazione" in PAGINA and ">CONFERMA</button>" in PAGINA
assert "Mostra immagine calibrata" not in PAGINA
assert PAGINA.index('id="riquadroCal"') < PAGINA.index('id="sceltaCal"') < PAGINA.index('id="confermaCal"') < PAGINA.index('id="msgSceltaCal"')
# il gestore di CONFERMA usa PUT calibrazione-attiva (via caricaNome) o DELETE calibration (via scollega), messaggio sotto la calibrata
gestore = PAGINA[PAGINA.index('$("confermaCal").onclick'):]
gestore = gestore[:gestore.index("};")]
assert 'caricaNome(nome, "msgSceltaCal")' in gestore and 'scollega("msgSceltaCal")' in gestore
assert 'method: "PUT"' in PAGINA and "/camera/calibrazione-attiva" in PAGINA
assert 'method: "DELETE"' in PAGINA and '"/camera/calibration?source="' in PAGINA
# voce vuota «nessuna», suffisso di risoluzione, combo ridisegnata con l'elenco e spenta senza sorgente
assert "— nessuna (non calibrata) —" in PAGINA and "— risoluzione diversa" in PAGINA
assert "disegnaSceltaCal();" in PAGINA and "= !$(\"sorgente\").value" in PAGINA
+2
View File
@@ -250,6 +250,8 @@ async def create_task(
file_type=data.file_type,
annotations_json=data.annotations_json,
vision_json=data.vision_json,
# #97: camera e modo di analisi (dict gia' validato dal modello)
acquisition_json=data.acquisition_json.model_dump() if data.acquisition_json else None,
# #53 #55: ciclo (default dal tipo), chiusura, campi extra
in_loop=data.in_loop if data.in_loop is not None else default_in_loop(data.task_type),
at_close=data.at_close,
+17 -1
View File
@@ -23,7 +23,7 @@ from src.backend.api.routers.files import (
from src.backend.config import settings
from src.backend.database import get_db
from src.backend.models.api.vision import (
VisionExecuteResponse, VisionMeasurementResult, VisionRecipeResponse,
VisionCheckResponse, VisionExecuteResponse, VisionMeasurementResult, VisionRecipeResponse,
VisionRecipeUploadResponse, VisionReferenceImageResponse,
)
from src.backend.models.orm.vision import VisionResult
@@ -164,6 +164,22 @@ async def execute(
)
@router.post("/check", response_model=VisionCheckResponse)
async def check(
task_id: int = Form(...),
image: UploadFile = File(...),
production_run_id: int | None = Form(None, gt=0),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_measurement_tec),
) -> VisionCheckResponse:
"""#97: analisi di un frame senza salvare nulla (ne' risultato ne' misure)."""
task = await _task_or_404(db, task_id)
report = await vision_service.check_task(
db, task, await _read_image(image), production_run_id=production_run_id,
)
return VisionCheckResponse(**report)
@router.get("/results/{result_id}/image")
async def get_result_image(
result_id: int,
@@ -0,0 +1,31 @@
"""recipe_tasks.acquisition_json (#97)
recipe_tasks.acquisition_json: camera e modo di analisi scelti nel task,
{"camera_url", "camera_source", "modo": "impulsiva" | "in_linea"}; NULL = la
camera della stazione dell'operatore, analisi impulsiva (come prima).
Revision ID: 021_task_acquisition
Revises: 020_station_camera
Create Date: 2026-10-24
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '021_task_acquisition'
down_revision: Union[str, None] = '020_station_camera'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# batch_alter_table come nelle migrazioni vicine: serve a SQLite
with op.batch_alter_table('recipe_tasks') as batch:
batch.add_column(sa.Column('acquisition_json', sa.JSON(), nullable=True))
def downgrade() -> None:
with op.batch_alter_table('recipe_tasks') as batch:
batch.drop_column('acquisition_json')
+29 -1
View File
@@ -2,8 +2,9 @@
import re
from typing import Any, Literal, Optional
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from src.backend.models.api.station import _normalize_http_url
from src.backend.models.orm.task import DEFAULT_LOOP_TASK_TYPES, TASK_TYPES
# Kept in step with the ORM enum so the two cannot drift apart.
@@ -159,6 +160,30 @@ class SubtaskResponse(BaseModel):
vision_output: Optional[str] = None
class AcquisitionConfig(BaseModel):
"""#97: camera (servizio + sorgente) e modo di analisi scelti nel task."""
camera_url: str = Field(..., max_length=500)
camera_source: str = Field(..., min_length=1, max_length=200)
modo: Literal["impulsiva", "in_linea"] = "impulsiva"
@field_validator("camera_url")
@classmethod
def _check_camera_url(cls, value: str) -> str:
# stessa normalizzazione delle stazioni; qui il vuoto non e' ammesso
normalized = _normalize_http_url(value, "camera_url")
if normalized is None:
raise ValueError("camera_url must not be empty")
return normalized
@field_validator("camera_source")
@classmethod
def _check_camera_source(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("camera_source must not be empty")
return value
class TaskCreate(BaseModel):
"""Schema for creating a task."""
title: str = Field(..., min_length=1, max_length=255)
@@ -169,6 +194,7 @@ class TaskCreate(BaseModel):
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
annotations_json: Optional[dict[str, Any]] = None
vision_json: Optional[dict] = None
acquisition_json: Optional[AcquisitionConfig] = None # #97
# #53: assente = default dal tipo (misure e check nel ciclo)
in_loop: Optional[bool] = None
at_close: bool = False
@@ -186,6 +212,7 @@ class TaskUpdate(BaseModel):
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
annotations_json: Optional[dict[str, Any]] = None
vision_json: Optional[dict] = None
acquisition_json: Optional[AcquisitionConfig] = None # #97
in_loop: Optional[bool] = None
at_close: Optional[bool] = None
fields: Optional[list[dict[str, Any]]] = None
@@ -207,6 +234,7 @@ class TaskResponse(BaseModel):
file_type: Optional[str] = None
annotations_json: Optional[dict[str, Any]] = None
vision_json: Optional[dict] = None
acquisition_json: Optional[AcquisitionConfig] = None # #97
in_loop: bool = False
at_close: bool = False
fields: Optional[list[dict[str, Any]]] = None
+10
View File
@@ -24,6 +24,16 @@ class VisionExecuteResponse(BaseModel):
failures: list[dict] = []
class VisionCheckResponse(BaseModel):
"""What `POST /api/vision/check` returns (#97): the analysis, nothing saved."""
outcome: str | None = None
overlay: list[dict] = []
failures: list[dict] = []
measurements: list[VisionMeasurementResult] = []
# esito della lettura della calibrazione: ok | no_camera | not_calibrated | unreachable
calibration: str
class VisionRecipeOutput(BaseModel):
"""One numeric output a recipe offers to the quotes."""
key: str
+3
View File
@@ -65,6 +65,9 @@ class RecipeTask(Base):
# Cartella immutabile (relativa a upload_path) con recipe.json e i file di
# meta della ricetta VisionSuite v2 (#46).
vision_recipe_dir: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
# #97: camera e modo di analisi del task, {camera_url, camera_source, modo};
# NULL = camera della stazione corrente, analisi impulsiva.
acquisition_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
# #53: il task fa parte del ciclo di produzione (default dal tipo: misure e
# check si', gli altri no; un task 'note' si puo' mettere nel ciclo a mano).
in_loop: Mapped[bool] = mapped_column(
+2
View File
@@ -117,6 +117,8 @@ async def _copy_tasks_to_version(
vision_json=task.vision_json,
# Cartella immutabile: basta il riferimento (#46).
vision_recipe_dir=task.vision_recipe_dir,
# #97: camera e modo di analisi seguono il task nella nuova versione.
acquisition_json=task.acquisition_json,
# #53 #55: ciclo, chiusura e campi extra seguono il task nella nuova versione.
in_loop=task.in_loop,
at_close=task.at_close,
+122 -14
View File
@@ -231,35 +231,36 @@ CAL_NOT_CALIBRATED = "not_calibrated" # l'acquisitore risponde 404 (o un valore
CAL_UNREACHABLE = "unreachable" # l'acquisitore non risponde
async def fetch_station_calibration(station: Station | None) -> tuple[dict | None, str]:
"""Legge la calibrazione dall'acquisitore della stazione (C1, #83).
async def fetch_calibration(
camera_url: str | None, camera_source: str | None, label: str = "",
) -> tuple[dict | None, str]:
"""Legge la calibrazione da un acquisitore (C1, #83; generalizzata in #97).
GET {camera_url}/camera/calibration?source={camera_source}. La calibrazione
vive nell'acquisitore: TMF la legge, non la tiene. Ritorna (dict, "ok")
oppure (None, motivo) per distinguere «non calibrata» da «irraggiungibile».
Un px_per_mm non finito o <= 0 conta come non calibrata.
Un px_per_mm non finito o <= 0 conta come non calibrata. `label` serve
solo ai log (stazione o task).
"""
if station is None or not station.camera_url or not station.camera_source:
if not camera_url or not camera_source:
return None, CAL_NO_CAMERA
url = f"{station.camera_url.rstrip('/')}/camera/calibration"
url = f"{camera_url.rstrip('/')}/camera/calibration"
try:
# timeout breve: e' una lettura di servizio, non deve bloccare l'execute
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(url, params={"source": station.camera_source})
response = await client.get(url, params={"source": camera_source})
except httpx.HTTPError as exc:
logger.warning("acquisitore della stazione %s non raggiungibile: %s", station.code, exc)
logger.warning("acquisitore %s non raggiungibile: %s", label, exc)
return None, CAL_UNREACHABLE
if response.status_code == status.HTTP_404_NOT_FOUND:
return None, CAL_NOT_CALIBRATED
if response.status_code != 200:
logger.warning(
"acquisitore della stazione %s: HTTP %s", station.code, response.status_code,
)
logger.warning("acquisitore %s: HTTP %s", label, response.status_code)
return None, CAL_UNREACHABLE
try:
body = response.json()
except ValueError:
logger.warning("acquisitore della stazione %s: risposta non JSON", station.code)
logger.warning("acquisitore %s: risposta non JSON", label)
return None, CAL_UNREACHABLE
px_per_mm = body.get("px_per_mm") if isinstance(body, dict) else None
if not (_is_measurable(px_per_mm) and px_per_mm > 0):
@@ -267,6 +268,33 @@ async def fetch_station_calibration(station: Station | None) -> tuple[dict | Non
return body, CAL_OK
async def fetch_station_calibration(station: Station | None) -> tuple[dict | None, str]:
"""Calibrazione dall'acquisitore della stazione (C1, #83): vedi fetch_calibration."""
if station is None:
return None, CAL_NO_CAMERA
return await fetch_calibration(
station.camera_url, station.camera_source, f"della stazione {station.code}",
)
def task_camera(task: RecipeTask, station: Station | None) -> tuple[str | None, str | None]:
"""Camera di un task (#97): acquisition_json, altrimenti quella della stazione."""
acquisition = task.acquisition_json
if isinstance(acquisition, dict) and acquisition.get("camera_url") and acquisition.get("camera_source"):
return acquisition["camera_url"], acquisition["camera_source"]
if station is not None:
return station.camera_url, station.camera_source
return None, None
async def fetch_task_calibration(
task: RecipeTask, station: Station | None,
) -> tuple[dict | None, str]:
"""Calibrazione della camera del task (#97), stessi esiti CAL_* di fetch_calibration."""
camera_url, camera_source = task_camera(task, station)
return await fetch_calibration(camera_url, camera_source, f"del task {task.id}")
async def get_station_calibration(station: Station | None) -> dict | None:
"""La calibrazione C1 della stazione, o None in ogni altro caso (#83)."""
calibration, _ = await fetch_station_calibration(station)
@@ -498,14 +526,15 @@ async def execute_task(
)
# Worker della stazione della sessione di produzione (#45); senza run o senza URL: centrale
station_url = None
# Calibrazione letta dall'acquisitore della stazione (#83); None = non c'e'
calibration = None
# Calibrazione letta dall'acquisitore (#83); None = non c'e'. #97: la camera e'
# quella del task se la ricetta ne sceglie una, altrimenti quella della stazione.
station = None
if run is not None:
station = (await db.execute(
select(Station).where(Station.id == run.station_id)
)).scalar_one_or_none()
station_url = (station.vision_worker_url or None) if station is not None else None
calibration = await get_station_calibration(station)
calibration, _ = await fetch_task_calibration(task, station)
if not task.vision_json:
raise HTTPException(
@@ -623,6 +652,85 @@ async def execute_task(
return result, saved, report
async def check_task(
db: AsyncSession,
task: RecipeTask,
image_bytes: bytes,
*,
production_run_id: int | None = None,
) -> dict:
"""Esegue il grafo come execute_task ma NON salva nulla (#97).
Anteprima per l'operatore (ingresso nel task, ciclo «in linea»): niente
VisionResult, niente file, niente misure. L'esito per quota usa
calculate_pass_fail sulle quote con vision_output, nell'ordine di
marker_number come execute. Le quote che il grafo non produce (pezzo non
ancora in posizione) restano fuori da `measurements` e il perche' sta in
`failures`: un'anteprima non deve dare 422 a ogni frame.
"""
if task.task_type not in VISION_TASK_TYPES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"task type {task.task_type!r} is not a camera measurement",
)
station = None
station_url = None
if production_run_id is not None:
# stesse regole di execute: 404 / 422 / 409 sulla sessione di produzione
run = await measurement_service.validate_run_for_version(
db, production_run_id, task.version_id,
)
station = (await db.execute(
select(Station).where(Station.id == run.station_id)
)).scalar_one_or_none()
station_url = (station.vision_worker_url or None) if station is not None else None
if not task.vision_json:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="this task carries no vision graph",
)
quotes = (await db.execute(
select(RecipeSubtask)
.where(RecipeSubtask.task_id == task.id)
.where(RecipeSubtask.vision_output.is_not(None))
.order_by(RecipeSubtask.marker_number)
)).scalars().all()
if not quotes:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="no quote on this task names a vision output",
)
# stessa calibrazione di execute: camera del task, altrimenti della stazione
calibration, cal_reason = await fetch_task_calibration(task, station)
worker_kwargs: dict = {}
if calibration is not None:
worker_kwargs["calibration"] = {"px_per_mm": float(calibration["px_per_mm"])}
report = await _call_worker(
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
worker_url=station_url,
**worker_kwargs,
)
outputs = report["outputs"]
measurements = [
{
"subtask_id": q.id,
"value": float(outputs[q.vision_output]),
"pass_fail": measurement_service.calculate_pass_fail(
float(outputs[q.vision_output]), q,
)[0],
}
for q in quotes if _is_measurable(outputs.get(q.vision_output))
]
return {
"outcome": report.get("outcome"),
"overlay": report.get("overlay") or [],
"failures": report.get("failures") or [],
"measurements": measurements,
"calibration": cal_reason,
}
def _reference_file_path(reference: VisionReferenceImage) -> Path:
return settings.upload_path / reference.path
+19
View File
@@ -341,3 +341,22 @@ class TestStationCameraMigration:
assert "vision_worker_url" in cols("stations") # la 019 resta
command.upgrade(cfg, "head") # e di nuovo su
assert {"camera_url", "camera_source"} <= cols("stations")
class TestTaskAcquisitionMigration:
"""021_task_acquisition: recipe_tasks.acquisition_json, su e giu (#97)."""
def test_021_upgrade_and_downgrade(self, sqlite_database_url):
db_path, _ = sqlite_database_url
cfg = _alembic_config()
def cols(table):
return {c["name"] for c in _inspect(db_path).get_columns(table)}
command.upgrade(cfg, "head")
assert "acquisition_json" in cols("recipe_tasks")
command.downgrade(cfg, "020_station_camera")
assert "acquisition_json" not in cols("recipe_tasks")
assert "camera_url" in cols("stations") # la 020 resta
command.upgrade(cfg, "head") # e di nuovo su
assert "acquisition_json" in cols("recipe_tasks")
+308
View File
@@ -0,0 +1,308 @@
"""#97: camera e modo di analisi nel task, POST /api/vision/check senza salvataggio.
Il worker e l'acquisitore non girano nei test: si fingono (fixture di
test_vision_dxf_compare e test_vision_station_worker).
"""
import pytest
from sqlalchemy import func, select
from src.backend.config import settings
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from src.backend.models.orm.vision import VisionResult
from src.backend.tests.conftest import auth_headers, create_test_recipe
from src.backend.tests.test_vision_copy_on_write import (
_current_task_and_subtask, _seed_measurement,
)
from src.backend.tests.test_vision_dxf_compare import ( # noqa: F401 (fixture acquisitore)
CAL, CAMERA_URL, acquisitore, redirect_uploads,
)
from src.backend.tests.test_vision_execute import an_image
from src.backend.tests.test_vision_station_worker import ( # noqa: F401 (fixture worker_calls)
_camera_task, _execute, _station_with_run, worker_calls,
)
TASK_CAMERA = "http://10.0.0.50:8300"
ACQ = {"camera_url": TASK_CAMERA, "camera_source": "camera:601020", "modo": "in_linea"}
async def _task_with_acquisition(db_session, user_id, acquisition=ACQ):
recipe, task = await _camera_task(db_session, user_id)
task.acquisition_json = acquisition
await db_session.commit()
return recipe, task
# --- modello e API del task ---------------------------------------------------
@pytest.mark.asyncio
async def test_create_task_stores_and_normalizes_acquisition(client, db_session, maker_user):
recipe = await create_test_recipe(db_session, maker_user.id)
resp = await client.post(
f"/api/recipes/{recipe.id}/tasks",
headers=auth_headers(maker_user),
json={
"title": "Camera", "task_type": "camera_measure",
"acquisition_json": {"camera_url": " http://h:1/ ", "camera_source": " camera:1 "},
},
)
assert resp.status_code == 201, resp.text
body = resp.json()
# url normalizzato come nelle stazioni, sorgente senza spazi, modo di default
expected = {"camera_url": "http://h:1", "camera_source": "camera:1", "modo": "impulsiva"}
assert body["acquisition_json"] == expected
db_session.expunge_all()
task = (await db_session.execute(
select(RecipeTask).where(RecipeTask.id == body["id"])
)).scalar_one()
assert task.acquisition_json == expected # sul DB, non solo nella risposta
got = await client.get(f"/api/tasks/{body['id']}", headers=auth_headers(maker_user))
assert got.json()["acquisition_json"] == expected
@pytest.mark.asyncio
async def test_task_without_acquisition_is_null(client, db_session, maker_user):
recipe = await create_test_recipe(db_session, maker_user.id)
resp = await client.get(f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user))
assert resp.json()[0]["acquisition_json"] is None
@pytest.mark.asyncio
@pytest.mark.parametrize("bad", [
{"camera_url": "ftp://h", "camera_source": "camera:1"},
{"camera_url": " ", "camera_source": "camera:1"},
{"camera_url": "http://h", "camera_source": " "},
{"camera_url": "http://h", "camera_source": "x" * 201},
{"camera_url": "http://h", "camera_source": "camera:1", "modo": "continua"},
{"camera_source": "camera:1"},
])
async def test_invalid_acquisition_is_422(client, db_session, maker_user, bad):
recipe = await create_test_recipe(db_session, maker_user.id)
resp = await client.post(
f"/api/recipes/{recipe.id}/tasks",
headers=auth_headers(maker_user),
json={"title": "Camera", "task_type": "camera_measure", "acquisition_json": bad},
)
assert resp.status_code == 422, resp.text
@pytest.mark.asyncio
async def test_update_sets_and_clears_acquisition(client, db_session, maker_user):
recipe = await create_test_recipe(db_session, maker_user.id)
_, task, _ = await _current_task_and_subtask(db_session, recipe.id)
resp = await client.put(
f"/api/tasks/{task.id}", headers=auth_headers(maker_user), json={"acquisition_json": ACQ},
)
assert resp.status_code == 200, resp.text
assert resp.json()["acquisition_json"] == ACQ
resp = await client.put(
f"/api/tasks/{task.id}", headers=auth_headers(maker_user), json={"acquisition_json": None},
)
assert resp.status_code == 200
assert resp.json()["acquisition_json"] is None # null = camera della stazione
@pytest.mark.asyncio
async def test_copy_on_write_preserves_acquisition(client, db_session, maker_user):
recipe = await create_test_recipe(db_session, maker_user.id)
_, task, _ = await _current_task_and_subtask(db_session, recipe.id)
task.acquisition_json = ACQ
await db_session.flush()
await _seed_measurement(db_session, recipe.id, maker_user.id)
db_session.expunge_all()
resp = await client.put(
f"/api/recipes/{recipe.id}", headers=auth_headers(maker_user),
json={"change_notes": "forza il copy-on-write"},
)
assert resp.status_code == 200
db_session.expunge_all()
version, new_task, _ = await _current_task_and_subtask(db_session, recipe.id)
assert version.version_number == 2
assert new_task.acquisition_json == ACQ
# --- calibrazione dalla camera del task ----------------------------------------
@pytest.mark.asyncio
async def test_execute_reads_calibration_from_the_task_camera(
client, db_session, measurement_tec_user, worker_calls, acquisitore,
):
recipe, task = await _task_with_acquisition(db_session, measurement_tec_user.id)
# stazione con camera propria: la camera del task prevale
station, run = await _station_with_run(db_session, measurement_tec_user, recipe, None)
station.camera_url, station.camera_source = CAMERA_URL, "camera:A1"
await db_session.commit()
await _execute(client, measurement_tec_user, task, run)
assert acquisitore["urls"] == [(f"{TASK_CAMERA}/camera/calibration", {"source": "camera:601020"})]
assert worker_calls[0]["calibration"] == {"px_per_mm": CAL["px_per_mm"]}
result = (await db_session.execute(select(VisionResult))).scalars().one()
assert result.calibration_snapshot["px_per_mm"] == CAL["px_per_mm"]
@pytest.mark.asyncio
async def test_execute_task_camera_works_without_a_run(
client, db_session, measurement_tec_user, worker_calls, acquisitore,
):
_, task = await _task_with_acquisition(db_session, measurement_tec_user.id)
await _execute(client, measurement_tec_user, task) # niente sessione di produzione
assert acquisitore["urls"][0][0] == f"{TASK_CAMERA}/camera/calibration"
assert "calibration" in worker_calls[0]
@pytest.mark.asyncio
async def test_execute_falls_back_to_the_station_camera(
client, db_session, measurement_tec_user, worker_calls, acquisitore,
):
recipe, task = await _camera_task(db_session, measurement_tec_user.id) # acquisition_json null
station, run = await _station_with_run(db_session, measurement_tec_user, recipe, None)
station.camera_url, station.camera_source = CAMERA_URL, "camera:A1"
await db_session.commit()
await _execute(client, measurement_tec_user, task, run)
assert acquisitore["urls"] == [(f"{CAMERA_URL}/camera/calibration", {"source": "camera:A1"})]
assert worker_calls[0]["calibration"] == {"px_per_mm": CAL["px_per_mm"]}
# --- POST /api/vision/check ----------------------------------------------------
async def _check(client, user, task, run=None):
data = {"task_id": str(task.id)}
if run is not None:
data["production_run_id"] = str(run.id)
return await client.post(
"/api/vision/check", headers=auth_headers(user),
files={"image": ("p.png", an_image(), "image/png")}, data=data,
)
async def _counts(db_session):
return tuple([
(await db_session.execute(select(func.count()).select_from(model))).scalar_one()
for model in (Measurement, VisionResult)
])
@pytest.mark.asyncio
async def test_check_saves_nothing_and_reports_pass_fail(
client, db_session, measurement_tec_user, monkeypatch, acquisitore,
):
_, task = await _task_with_acquisition(db_session, measurement_tec_user.id)
seen = {}
async def fake_call(image_bytes, graph, **kwargs):
seen.update(kwargs)
return {"outputs": {"t1.diameter": 11.0}, "failures": [{"tool_name": "t"}],
"overlay": [{"kind": "line"}], "outcome": "fail",
"engine_version": "e" * 40, "duration_ms": 3}
monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call)
before = await _counts(db_session)
resp = await _check(client, measurement_tec_user, task)
assert resp.status_code == 200, resp.text
body = resp.json()
quote = (await db_session.execute(
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
)).scalars().one()
assert body == {
"outcome": "fail", "overlay": [{"kind": "line"}], "failures": [{"tool_name": "t"}],
"measurements": [{"subtask_id": quote.id, "value": 11.0, "pass_fail": "fail"}],
"calibration": "ok",
}
assert seen["calibration"] == {"px_per_mm": CAL["px_per_mm"]} # stessa calibrazione di execute
assert await _counts(db_session) == before # niente misure ne' VisionResult
assert not list(settings.upload_path.rglob("*.overlay.json")) # ne' file
@pytest.mark.asyncio
@pytest.mark.parametrize("value,expected", [(10.0, "pass"), (10.4, "warning"), (9.0, "fail")])
async def test_check_pass_fail_levels(
client, db_session, measurement_tec_user, monkeypatch, value, expected,
):
_, task = await _camera_task(db_session, measurement_tec_user.id)
async def fake_call(image_bytes, graph, **kwargs):
return {"outputs": {"t1.diameter": value}, "failures": [], "engine_version": "e" * 40}
monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call)
resp = await _check(client, measurement_tec_user, task)
assert resp.json()["measurements"][0]["pass_fail"] == expected
assert resp.json()["calibration"] == "no_camera" # senza camera ne' stazione
@pytest.mark.asyncio
async def test_check_calibration_reasons(
client, db_session, measurement_tec_user, worker_calls, acquisitore,
):
_, task = await _task_with_acquisition(db_session, measurement_tec_user.id)
acquisitore["cal"] = None
assert (await _check(client, measurement_tec_user, task)).json()["calibration"] == "not_calibrated"
acquisitore["cal"] = "down"
assert (await _check(client, measurement_tec_user, task)).json()["calibration"] == "unreachable"
assert "calibration" not in worker_calls[-1] # senza calibrazione la ricetta usa la sua
@pytest.mark.asyncio
async def test_check_omits_quotes_the_graph_did_not_produce(
client, db_session, measurement_tec_user, monkeypatch,
):
_, task = await _camera_task(db_session, measurement_tec_user.id)
async def fake_call(image_bytes, graph, **kwargs):
return {"outputs": {}, "failures": [{"tool_name": "blob"}], "engine_version": "e" * 40}
monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call)
resp = await _check(client, measurement_tec_user, task)
assert resp.status_code == 200 # un'anteprima non da' 422 a ogni frame
assert resp.json()["measurements"] == []
assert resp.json()["failures"] == [{"tool_name": "blob"}]
@pytest.mark.asyncio
async def test_check_422_without_graph_or_quotes_or_vision_type(
client, db_session, measurement_tec_user, worker_calls,
):
_, task = await _camera_task(db_session, measurement_tec_user.id)
task.vision_json = None
await db_session.commit()
assert (await _check(client, measurement_tec_user, task)).status_code == 422
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
quote = (await db_session.execute(
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
)).scalars().one()
quote.vision_output = None
await db_session.commit()
assert (await _check(client, measurement_tec_user, task)).status_code == 422
task.task_type = "measure"
await db_session.commit()
assert (await _check(client, measurement_tec_user, task)).status_code == 422
@pytest.mark.asyncio
async def test_check_with_run_uses_station_worker_and_station_camera(
client, db_session, measurement_tec_user, worker_calls, acquisitore,
):
recipe, task = await _camera_task(db_session, measurement_tec_user.id)
station, run = await _station_with_run(
db_session, measurement_tec_user, recipe, "http://10.0.0.7:8100")
station.camera_url, station.camera_source = CAMERA_URL, "camera:A1"
await db_session.commit()
before = await _counts(db_session)
resp = await _check(client, measurement_tec_user, task, run)
assert resp.status_code == 200, resp.text
assert worker_calls[0]["worker_url"] == "http://10.0.0.7:8100"
assert acquisitore["urls"][0][0] == f"{CAMERA_URL}/camera/calibration"
assert await _counts(db_session) == before
@pytest.mark.asyncio
async def test_check_requires_login(client, db_session, measurement_tec_user, worker_calls):
_, task = await _camera_task(db_session, measurement_tec_user.id)
resp = await client.post(
"/api/vision/check", files={"image": ("p.png", an_image(), "image/png")},
data={"task_id": str(task.id)},
)
assert resp.status_code in (401, 403)