Compare commits

..

1 Commits

Author SHA1 Message Date
Adriano f504c86491 Acquisizione: immagine da file come sorgente, riquadri grezza e calibrata affiancati (Refs #92)
Pagina: Sorgente in cima alla tab Acquisizione con carica/rimuovi immagine,
riquadro Calibrata con casella, calibrazione sempre dal frame grezzo della
sorgente, ciclo anteprima sospeso nella tab Calibrazione.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 16:27:20 +02:00
5 changed files with 337 additions and 536 deletions
+276 -145
View File
@@ -12,6 +12,11 @@ Parla con le rotte dello stesso servizio: /camera/sources, /camera/anteprima,
visibile in entrambe. Calibrazioni salvate con nome in un archivio unico
(/camera/calibrazioni, /camera/calibrazione-attiva); il calcolo non salva piu':
si salva dal calcolo_id con un nome.
#92: l'immagine da file e' una SORGENTE di acquisizione (POST /camera/immagini), non
un'opzione della calibrazione: la calibrazione usa sempre il frame grezzo della sorgente
scelta nella tab Acquisizione. Sorgente in cima alla tab; riquadri «Immagine» (grezza) e,
con la casella, «Calibrata» (/camera/anteprima/calibrata) affiancati; il ciclo anteprima
si sospende mentre la tab Calibrazione e' aperta.
Dipende da: niente. E' una stringa, cosi' il pacchetto compilato non ha file dati.
"""
@@ -61,10 +66,19 @@ PAGINA = r"""<!doctype html>
display: flex; align-items: center; justify-content: center; margin-top: 10px; overflow: hidden; }
.cornice { position: relative; display: inline-block; line-height: 0; max-width: 100%; }
.cornice img { max-width: 100%; max-height: calc(100vh - 330px); min-height: 200px; border: 0; margin: 0; }
#mirino { position: absolute; inset: 0; pointer-events: none;
.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 { padding: 30px 16px; color: var(--secondario); text-align: center; }
#vuoto, #vuotoCal { 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); }
.riquadro .titolo { font-size: .88rem; font-weight: 600; }
.riquadro .titolo span { font-weight: 400; font-size: .78rem; color: var(--secondario); margin-left: 6px; }
.riquadro .palco { margin-top: 4px; }
.sorgente-riga { display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-end; }
.sorgente-riga .campo-sorgente { flex: 1 1 260px; min-width: 0; }
.sorgente-riga select { width: 100%; }
.indicatori { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin-top: 10px; }
.indicatore { background: var(--sfondo); border: 1px solid var(--bordo); border-radius: 6px; padding: 8px 10px; }
.indicatore .nome { font-size: .78rem; color: var(--secondario); }
@@ -94,10 +108,9 @@ PAGINA = r"""<!doctype html>
.scheda-cal .azioni button { padding: 3px 10px; font-size: .82rem; }
.segno { display: inline-block; font-size: .72rem; padding: 0 6px; border-radius: 9px; background: var(--ok); color: #052e16; margin-left: 6px; }
.segno.diversa { background: var(--avviso); color: #422006; }
.origine label { display: inline-block; margin: 0 14px 4px 0; color: var(--testo); font-size: .92rem; }
input[type=radio] { accent-color: var(--accento); }
@media (max-width: 900px) {
.griglia { grid-template-columns: minmax(0, 1fr); }
.riquadri.doppio { grid-template-columns: minmax(0, 1fr); } /* #92: in colonna */
.cornice img { max-height: 60vh; }
}
@media (max-width: 500px) { /* #91: utilizzabile anche stretta (400 px) */
@@ -112,125 +125,134 @@ PAGINA = r"""<!doctype html>
<main>
<h1>Acquisizione camera</h1>
<!-- #91: due tab; la colonna dell'anteprima resta visibile in entrambe -->
<!-- #91: due tab; #92: l'anteprima sta nella tab Acquisizione e si sospende nella tab Calibrazione -->
<div class="tabs" role="tablist" aria-label="Sezioni della pagina">
<button id="tabAcq" type="button" role="tab" aria-selected="true" aria-controls="pannelloAcq">Acquisizione</button>
<button id="tabCal" type="button" role="tab" aria-selected="false" aria-controls="pannelloCal" tabindex="-1">Calibrazione</button>
</div>
<div class="griglia">
<div class="colonna">
<!-- #87: sezione Anteprima -->
<section class="scheda" id="sezioneAnteprima">
<h2>Anteprima</h2>
<div class="riga">
<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="grezzo" type="checkbox"> Immagine grezza (senza correzione)</label>
<label style="margin:0 0 6px"><input id="mirinoCasella" type="checkbox"> Mirino centrale</label>
<div class="colonna" id="pannelloAcq" role="tabpanel" aria-labelledby="tabAcq">
<!-- #92: Sorgente in cima, a larghezza piena: camere, cartella e immagini caricate da file -->
<section class="scheda" id="sezioneSorgente">
<h2>Sorgente</h2>
<div class="sorgente-riga">
<div class="campo-sorgente">
<label for="sorgente">Camera o immagine</label>
<select id="sorgente"></select>
</div>
<div class="palco">
<div id="vuoto">Anteprima ferma. Premere «Avvia anteprima» o «Scatta».</div>
<div class="cornice" id="cornice" hidden>
<img id="immagine" alt="Anteprima della camera">
<div id="mirino" hidden></div>
</div>
</div>
<div class="indicatori">
<div class="indicatore">
<div class="nome">Nitidezza</div>
<div class="valore" id="nitidezza">-</div>
<div class="barra"><div id="barraNitidezza"></div></div>
<div class="piccolo">massimo visto: <span id="nitidezzaMax">-</span>
<button id="azzeraMax" class="secondario" style="padding:1px 7px;font-size:.75rem;margin-left:6px">Azzera</button></div>
</div>
<div class="indicatore">
<div class="nome">Saturati</div>
<div class="valore" id="saturati">-</div>
<div class="piccolo">pixel a 250 o più; da tenere sotto l'1 %</div>
</div>
<div class="indicatore">
<div class="nome">Livello medio</div>
<div class="valore" id="media">-</div>
<div class="piccolo">scala 0-255</div>
</div>
<div class="indicatore">
<div class="nome">Frame pieno</div>
<div class="valore" id="dimensioni">-</div>
<div class="piccolo" id="raddrizzato">&nbsp;</div>
</div>
</div>
<div id="msgAnteprima" class="messaggio"></div>
</section>
</div>
<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>
</div>
<div class="stato" id="statoSorgente">-</div>
<div id="msgSorgente" class="messaggio"></div>
</section>
<div class="colonna" id="pannelloAcq" role="tabpanel" aria-labelledby="tabAcq">
<!-- #87: sezione Sorgente -->
<section class="scheda" id="sezioneSorgente">
<h2>Sorgente</h2>
<label for="sorgente">Camera o cartella</label>
<select id="sorgente"></select>
<div class="stato" id="statoSorgente">-</div>
<div id="msgSorgente" class="messaggio"></div>
</section>
<!-- #87: sezione Anteprima; #92: barra, due riquadri affiancati (grezza | calibrata), indicatori dal frame grezzo -->
<section class="scheda" id="sezioneAnteprima">
<h2>Anteprima</h2>
<div class="riga">
<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="mirinoCasella" type="checkbox"> Mirino centrale</label>
</div>
<div class="riquadri" id="riquadri">
<div class="riquadro">
<div class="titolo">Immagine<span>grezza, senza correzione</span></div>
<div class="palco">
<div id="vuoto">Anteprima ferma. Premere «Avvia anteprima» o «Scatta».</div>
<div class="cornice" id="cornice" hidden>
<img id="immagine" alt="Anteprima grezza della sorgente">
<div id="mirino" class="mirino" hidden></div>
</div>
</div>
</div>
<div class="riquadro" id="riquadroCal" hidden>
<div class="titolo">Calibrata<span id="nomeCalibrata"></span></div>
<div class="palco">
<div id="vuotoCal">Premere «Avvia anteprima» o «Scatta».</div>
<div class="cornice" id="corniceCal" hidden>
<img id="immagineCal" alt="Anteprima della sorgente raddrizzata dalla calibrazione">
<div id="mirinoCal" class="mirino" hidden></div>
</div>
</div>
</div>
</div>
<div class="indicatori">
<div class="indicatore">
<div class="nome">Nitidezza</div>
<div class="valore" id="nitidezza">-</div>
<div class="barra"><div id="barraNitidezza"></div></div>
<div class="piccolo">massimo visto: <span id="nitidezzaMax">-</span>
<button id="azzeraMax" class="secondario" style="padding:1px 7px;font-size:.75rem;margin-left:6px">Azzera</button></div>
</div>
<div class="indicatore">
<div class="nome">Saturati</div>
<div class="valore" id="saturati">-</div>
<div class="piccolo">pixel a 250 o più; da tenere sotto l'1 %</div>
</div>
<div class="indicatore">
<div class="nome">Livello medio</div>
<div class="valore" id="media">-</div>
<div class="piccolo">scala 0-255</div>
</div>
<div class="indicatore">
<div class="nome">Frame pieno</div>
<div class="valore" id="dimensioni">-</div>
<div class="piccolo" id="raddrizzato">&nbsp;</div>
</div>
</div>
<div id="msgAnteprima" class="messaggio"></div>
</section>
<!-- #87: sezione Parametri camera -->
<section class="scheda" id="sezioneParametri">
<h2>Parametri camera</h2>
<div id="parametri"><div class="nota">-</div></div>
<div class="riga" id="salvaParametriRiga" hidden>
<button id="salvaParametri" class="secondario">Salva come predefiniti</button>
</div>
<div id="msgParametri" class="messaggio"></div>
</section>
</div>
<!-- #87: sezione Parametri camera -->
<section class="scheda" id="sezioneParametri">
<h2>Parametri camera</h2>
<div id="parametri"><div class="nota">-</div></div>
<div class="riga" id="salvaParametriRiga" hidden>
<button id="salvaParametri" class="secondario">Salva come predefiniti</button>
</div>
<div id="msgParametri" class="messaggio"></div>
</section>
</div>
<!-- #91: tab Calibrazione (colonna destra al posto di Sorgente e Parametri) -->
<div class="colonna" id="pannelloCal" role="tabpanel" aria-labelledby="tabCal" hidden>
<section class="scheda" id="sezioneCalibrazione">
<h2>Calibrazione</h2>
<div class="stato" style="margin:0 0 10px">Sorgente: <strong id="calSorgente">-</strong> (si cambia nella tab Acquisizione)</div>
<div class="origine">
<label for="origSorgente"><input id="origSorgente" name="origine" type="radio" value="sorgente" checked> Sorgente selezionata</label>
<label for="origFile"><input id="origFile" name="origine" type="radio" value="file"> Immagine da file</label>
</div>
<div class="riga" style="margin-top:4px">
<button id="scegliFile" class="secondario">Scegli immagine...</button>
<input id="file" type="file" accept="image/*" hidden>
<span id="nomeFile" class="nota" style="margin:0 0 6px">nessuna immagine scelta</span>
</div>
<!-- #91: tab Calibrazione; #92: sempre sul frame grezzo della sorgente scelta in Acquisizione, niente file qui -->
<div class="griglia" id="pannelloCal" role="tabpanel" aria-labelledby="tabCal" hidden>
<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>
<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>
</div>
<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>
<div id="messaggio" class="messaggio"></div>
<div id="risultato" hidden style="margin-top:10px">
<strong>Risultato del calcolo</strong>
<div id="origineCalcolo" class="nota" style="margin:2px 0 6px"></div>
<table id="tabella"></table>
<div id="avviso" class="avviso"></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>
<div><label for="nomeCal">Nome</label><input id="nomeCal" type="text" maxlength="60" size="22" autocomplete="off"></div>
<button id="salva">Salva</button>
</div>
<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.
Calcolando dalla sorgente l'anteprima si sospende e poi riprende.</p>
<div id="messaggio" class="messaggio"></div>
<div id="msgSalva" class="messaggio"></div>
<img id="overlay" alt="Angoli trovati sulla scacchiera" style="max-width:100%;margin-top:8px;border:1px solid var(--bordo);border-radius:6px">
</div>
</section>
<div id="risultato" hidden style="margin-top:10px">
<strong>Risultato del calcolo</strong>
<div id="origineCalcolo" class="nota" style="margin:2px 0 6px"></div>
<table id="tabella"></table>
<div id="avviso" class="avviso"></div>
<div class="riga" style="margin-top:10px">
<div><label for="nomeCal">Nome</label><input id="nomeCal" type="text" maxlength="60" size="22" autocomplete="off"></div>
<button id="salva">Salva</button>
</div>
<div id="msgSalva" class="messaggio"></div>
<img id="overlay" alt="Angoli trovati sulla scacchiera" style="max-width:100%;margin-top:8px;border:1px solid var(--bordo);border-radius:6px">
</div>
</section>
<section class="scheda" id="sezioneSalvate">
<h2>Calibrazioni salvate</h2>
<div id="stato" class="stato">-</div>
<div id="calibrazioni" class="elenco"></div> <!-- #91: una scheda per calibrazione, niente tabella larga -->
<div id="msgElenco" class="messaggio"></div>
</section>
</div>
<section class="scheda" id="sezioneSalvate">
<h2>Calibrazioni salvate</h2>
<div id="stato" class="stato">-</div>
<div id="calibrazioni" class="elenco"></div> <!-- #91: una scheda per calibrazione, niente tabella larga -->
<div id="msgElenco" class="messaggio"></div>
</section>
</div>
</main>
@@ -289,8 +311,11 @@ async function dettaglio(risposta) {
// ---- Sorgente
async function caricaSorgenti() {
// #92: scegli = nome da selezionare (immagine appena caricata); senza, l'ultima scelta ricordata
async function caricaSorgenti(scegli) {
var sel = $("sorgente");
var prima = sel.value;
sel.textContent = "";
try {
var r = await fetch("/camera/sources");
var j = await r.json();
@@ -298,23 +323,84 @@ async function caricaSorgenti() {
var o = document.createElement("option");
o.value = s.name;
o.textContent = s.label || s.name;
o.dataset.kind = s.kind || ""; // #92: «image» = immagine caricata da file
sel.appendChild(o);
});
} catch (e) { dire("msgSorgente", "Elenco delle sorgenti non disponibile.", "errore"); }
if (!sel.options.length) dire("msgSorgente", "Nessuna sorgente camera trovata.", "errore");
var ricordata = memoria("tmf.acq.sorgente"); // #87: ultima scelta ricordata
if (ricordata) {
for (var i = 0; i < sel.options.length; i++) if (sel.options[i].value === ricordata) sel.value = ricordata;
var voluta = scegli || (prima === "" ? memoria("tmf.acq.sorgente") : prima); // #87: ultima scelta ricordata
if (voluta) {
for (var i = 0; i < sel.options.length; i++) if (sel.options[i].value === voluta) sel.value = voluta;
}
cambioSorgente(false);
cambioSorgente(!!scegli);
}
// #92: la sorgente selezionata e' un'immagine caricata (si puo' rimuovere)?
function sorgenteImmagine() {
var sel = $("sorgente");
return sel.selectedIndex >= 0 && sel.options[sel.selectedIndex].dataset.kind === "image";
}
// #92: «Carica immagine da file...» -> POST /camera/immagini, poi la nuova immagine diventa la sorgente
$("caricaImmagine").onclick = function () { $("fileImmagine").click(); };
$("fileImmagine").onchange = async function () {
var file = $("fileImmagine").files[0];
$("fileImmagine").value = ""; // lo stesso file si puo' ricaricare
if (!file) return;
var f = new FormData();
f.append("image", file);
$("caricaImmagine").disabled = true;
dire("msgSorgente", "Caricamento di «" + file.name + "»...", "");
try {
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);
dire("msgSorgente", "Immagine «" + j.source + "» caricata: premere «Scatta» per vederla.", "ok");
} catch (e) {
dire("msgSorgente", "Servizio non raggiungibile.", "errore");
} finally {
$("caricaImmagine").disabled = false;
}
};
// #92: «Rimuovi immagine» con doppio clic entro 4 s (niente finestre del browser)
var R = { timer: null };
function annullaRimuovi() {
clearTimeout(R.timer);
R.timer = null;
$("rimuoviImmagine").textContent = "Rimuovi immagine";
}
$("rimuoviImmagine").onclick = async function () {
var nome = $("sorgente").value;
if (!nome || !sorgenteImmagine()) return;
if (R.timer === null) {
$("rimuoviImmagine").textContent = "Confermi? Clic ancora";
R.timer = setTimeout(annullaRimuovi, 4000);
return;
}
annullaRimuovi();
fermaAnteprima(); // la sorgente sta per sparire: niente richieste in volo su di lei
while (A.inVolo) await pausa(50);
try {
var r = await fetch("/camera/immagini?nome=" + encodeURIComponent(nome), { method: "DELETE" });
if (!r.ok) { dire("msgSorgente", await dettaglio(r), "errore"); return; }
await caricaSorgenti("");
dire("msgSorgente", "Immagine «" + nome + "» rimossa.", "ok");
} catch (e) {
dire("msgSorgente", "Servizio non raggiungibile.", "errore");
}
};
// #87: cambiando sorgente si azzerano gli indicatori e si rileggono parametri e calibrazione
function cambioSorgente(salva) {
if (salva) memoria("tmf.acq.sorgente", $("sorgente").value);
var era = A.attiva;
fermaAnteprima();
azzeraIndicatori();
svuotaRiquadri(); // #92: il frame vecchio non e' della nuova sorgente
annullaRimuovi();
$("rimuoviImmagine").hidden = !sorgenteImmagine();
dire("msgAnteprima", "");
dire("msgElenco", ""); // #91
aggiornaStato();
@@ -324,7 +410,8 @@ function cambioSorgente(salva) {
// ---- Anteprima (#87)
var A = { attiva: false, gen: 0, inVolo: false, url: null, nitMax: 0, ris: null }; // #91: ris = {w, h} dell'ultimo frame
// #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 };
function azzeraIndicatori() {
A.nitMax = 0;
@@ -350,6 +437,52 @@ function mostraImmagine(blob) {
$("vuoto").hidden = true;
}
// #92: riquadro «Calibrata»: l'immagine raddrizzata oppure il motivo per cui non c'e' (404/409 del servizio)
function mostraCalibrata(blob, nome) {
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 + "»" : "";
}
function messaggioCalibrata(testo) {
$("corniceCal").hidden = true;
$("vuotoCal").hidden = false;
$("vuotoCal").textContent = testo;
$("nomeCalibrata").textContent = "";
}
// #92: cambio sorgente: via i due frame, nessuna immagine di un'altra sorgente
function svuotaRiquadri() {
A.seqCal++; // una risposta calibrata ancora in volo non vale piu'
$("cornice").hidden = true;
$("vuoto").hidden = false;
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;
var n = ++A.seqCal;
try {
var r = await fetch("/camera/anteprima/calibrata", {
method: "POST",
headers: { "Content-Type": "application/json" },
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
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);
} catch (e) {
if (n === A.seqCal) messaggioCalibrata("Servizio non raggiungibile.");
}
}
function aggiornaIndicatori(h) {
var nit = parseFloat(h.get("X-Nitidezza"));
var sat = parseFloat(h.get("X-Saturati-Pct"));
@@ -372,8 +505,7 @@ function aggiornaIndicatori(h) {
var nw = parseInt(w, 10), na = parseInt(a, 10);
if (!A.ris || A.ris.w !== nw || A.ris.h !== na) { A.ris = { w: nw, h: na }; segnaRisoluzioni(); }
}
var r = h.get("X-Raddrizzato");
$("raddrizzato").textContent = r === "si" ? "raddrizzata dalla calibrazione" : r === "no" ? "non raddrizzata" : " ";
$("raddrizzato").textContent = "frame grezzo"; // #92: l'indicatore viene sempre dal frame grezzo
}
// una richiesta di anteprima; ritorna "ok" | "errore" | "fatale" (errore che non passa riprovando)
@@ -381,12 +513,13 @@ async function unaAnteprima(lato) {
var sorgente = $("sorgente").value;
if (!sorgente) { dire("msgAnteprima", "Scegliere una sorgente.", "errore"); return "fatale"; }
var g = A.gen;
A.lato = lato;
A.inVolo = true;
try {
var r = await fetch("/camera/anteprima", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source: sorgente, grezzo: $("grezzo").checked, lato_max: lato, timeout_ms: 5000 })
body: JSON.stringify({ source: sorgente, grezzo: true, lato_max: lato, timeout_ms: 5000 }) // #92: il riquadro «Immagine» e' sempre grezzo
});
if (!r.ok) {
var d = await dettaglio(r);
@@ -398,6 +531,7 @@ async function unaAnteprima(lato) {
mostraImmagine(blob);
aggiornaIndicatori(r.headers);
dire("msgAnteprima", "");
await aggiornaCalibrata(lato); // #92: stesso frame, raddrizzato; resta in volo finche' non risponde
return "ok";
} catch (e) {
if (g === A.gen) dire("msgAnteprima", "Servizio non raggiungibile.", "errore");
@@ -447,7 +581,15 @@ $("avvia").onclick = avviaAnteprima;
$("ferma").onclick = fermaAnteprima;
$("scatta").onclick = scatta;
$("azzeraMax").onclick = function () { A.nitMax = 0; $("nitidezzaMax").textContent = "-"; $("barraNitidezza").style.width = "0"; };
$("mirinoCasella").onchange = function () { $("mirino").hidden = !$("mirinoCasella").checked; };
// #92: il mirino vale per entrambi i riquadri; la casella «Mostra immagine calibrata» apre il riquadro e raddrizza il frame gia' tenuto
$("mirinoCasella").onchange = function () { $("mirino").hidden = $("mirinoCal").hidden = !$("mirinoCasella").checked; };
function mostraRiquadroCal() {
var si = $("mostraCal").checked;
$("riquadroCal").hidden = !si;
$("riquadri").className = "riquadri" + (si ? " doppio" : "");
if (si) aggiornaCalibrata();
}
$("mostraCal").onchange = mostraRiquadroCal;
// ---- Parametri camera (#87)
@@ -676,6 +818,10 @@ 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(); }
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
}
$("tabAcq").onclick = function () { scegliTab("acq", true); };
@@ -770,7 +916,7 @@ async function aggiornaStato() {
stato.appendChild(titolo);
stato.appendChild(t);
stato.appendChild(quando);
$("statoSorgente").textContent = "calibrata: " + fmt(d.px_per_mm, 2) + " px/mm";
$("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
}
} catch (e) {
@@ -877,8 +1023,8 @@ async function caricaNome(nome) {
if (sorgente !== $("sorgente").value) return;
if (!r.ok) { dire("msgElenco", await dettaglio(r), "errore"); return; }
dire("msgElenco", "Calibrazione «" + nome + "» caricata sulla sorgente.", "ok");
$("raddrizzato").textContent = $("grezzo").checked ? "non raddrizzata" : "raddrizzata dalla calibrazione"; // il prossimo frame conferma
aggiornaStato();
aggiornaCalibrata(); // #92: il riquadro calibrata usa subito la nuova calibrazione
} catch (e) {
dire("msgElenco", "Servizio non raggiungibile.", "errore");
}
@@ -891,11 +1037,11 @@ async function scollega() {
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");
if (r.ok) $("raddrizzato").textContent = "non raddrizzata";
} catch (e) {
dire("msgElenco", "Servizio non raggiungibile.", "errore");
}
aggiornaStato();
aggiornaCalibrata(); // #92: senza calibrazione il riquadro mostra il 409 del servizio
}
// «Elimina» di una riga: conferma in pagina con un secondo clic entro 4 s (niente finestre del browser)
@@ -927,25 +1073,18 @@ async function eliminaNome(nome, btn) {
// #91: il calcolo non salva mai; il servizio tiene il risultato e ne da' il calcolo_id per «Salva»
async function calcola() {
var daFile = $("origFile").checked;
var sorgente = $("sorgente").value;
if (!daFile && !sorgente) { messaggio("Scegliere una sorgente nella tab Acquisizione.", "errore"); return; }
var sorgente = $("sorgente").value; // #92: sempre la sorgente della tab Acquisizione, frame grezzo
if (!sorgente) { messaggio("Scegliere una sorgente nella tab Acquisizione.", "errore"); return; }
var lato = leggiNumero($("quadrato").value);
if (!(lato > 0)) { messaggio("Il lato dello scacco deve essere un numero maggiore di zero.", "errore"); return; }
var f = new FormData();
f.append("square_mm", String(lato)); // al servizio sempre con il punto
if (daFile) {
var file = $("file").files[0];
if (!file) { messaggio("Scegliere un file immagine.", "errore"); return; }
f.append("image", file);
} else {
f.append("source", sorgente);
}
var eraAttiva = !daFile && A.attiva; // #87: da sorgente l'anteprima si sospende e poi riprende
if (eraAttiva) fermaAnteprima();
f.append("source", sorgente);
fermaAnteprima(); // #92: in questa tab il ciclo e' gia' sospeso; qui 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);
var r = await fetch("/camera/calibrate", { method: "POST", body: f });
if (!r.ok) { messaggio(await dettaglio(r), "errore"); return; }
var d = await r.json();
@@ -963,7 +1102,6 @@ async function calcola() {
} finally {
$("acquisisci").disabled = false;
$("salva").disabled = false;
if (eraAttiva) avviaAnteprima();
}
}
@@ -1007,14 +1145,7 @@ async function salvaNome() {
$("acquisisci").onclick = calcola;
$("salva").onclick = salvaNome;
$("nomeCal").onkeydown = function (e) { if (e.key === "Enter") salvaNome(); };
// #87: il pulsante del file e' in italiano, l'input nativo resta nascosto
$("scegliFile").onclick = function () { $("file").click(); };
$("file").onchange = function () {
$("nomeFile").textContent = $("file").files[0] ? $("file").files[0].name : "nessuna immagine scelta";
if ($("file").files[0]) $("origFile").checked = true; // #91: scelto un file, l'origine diventa il file
};
$("sorgente").onchange = function () { cambioSorgente(true); };
$("sorgente").onchange = function () { dire("msgSorgente", ""); cambioSorgente(true); }; // #92
scegliTab(memoria("tmf.acq.tab") === "cal" ? "cal" : "acq", false); // #91: tab ricordata
caricaSorgenti();
</script>
+5 -109
View File
@@ -68,9 +68,6 @@ 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):
@@ -85,10 +82,8 @@ from __future__ import annotations
import argparse
import logging
import os
import re # #92: nome sicuro per le immagini caricate
import sys
import threading
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
@@ -101,7 +96,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 _ESTENSIONI, SourceNotFound, list_sources, open_source # #92: _ESTENSIONI = tipi ammessi per le immagini caricate
from visionsuite.camera.sources import SourceNotFound, list_sources, open_source
# #83: calibrazione e pagina stanno accanto a questo file (che non e' un pacchetto)
sys.path.insert(0, str(Path(__file__).resolve().parent))
@@ -153,8 +148,6 @@ 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)
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
@@ -186,69 +179,15 @@ def create_app(folder: Path | str | None = None,
@app.get("/camera/sources")
def camera_sources():
voci = list_sources(None, cameras=True, config=_config())
# #92: camera:file (replay) non e' mai configurato in TMF -> fuori dall'elenco
elenco = [{"name": v.name, "kind": v.kind, "label": v.label}
for v in voci if v.kind in ("camera", "virtual") and v.name != "camera:file"]
# #92: in coda le immagini caricate, che sono sorgenti a pieno titolo
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)
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)
return {"sources": [{"name": v.name, "kind": v.kind, "label": v.label}
for v in voci if v.kind in ("camera", "virtual")]}
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, 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
sorgente = open_source(nome, None, config=_config())
# TieVision-Core#341 / #89: free-run, serve la scena di adesso, non il frame vecchio in coda
if hasattr(sorgente, "solo_ultimo_frame"):
try:
@@ -475,12 +414,7 @@ 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")
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)
frame = _grab(nome, timeout)
raddrizzato = False
if not grezzo: # come /camera/grab
mappa = archivio.leggi_mappa(nome)
@@ -508,44 +442,6 @@ 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/anteprima/calibrata")
def camera_anteprima_calibrata(corpo: dict = Body(default=None)):
"""#92: l'ultimo frame grezzo dell'anteprima, raddrizzato; NON legge un frame nuovo."""
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: # planare: ha solo scala, nessuna mappa da applicare
raise HTTPException(409, f"la calibrazione «{attiva}» non ha una mappa di correzione (planare)")
corretto = calibrazione.raddrizza(frame, mappa)
if corretto is None: # risoluzione diversa da quella calibrata
ch, cw = mappa.shape[:2]
alt, larg = frame.shape[:2]
raise HTTPException(409, f"la calibrazione «{attiva}» è per immagini {cw}×{ch}, "
f"il frame è {larg}×{alt}")
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": "si", "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)
-263
View File
@@ -1,263 +0,0 @@
"""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_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_409(client):
_carica(client, PIANA, una_scacchiera())
_calibra_e_attiva(client, PIANA, "Piana", cols="9", rows="6")
assert _anteprima(client, PIANA).status_code == 200
r = _calibrata(client, PIANA)
assert r.status_code == 409
assert r.json()["detail"] == "la calibrazione «Piana» non ha una mappa di correzione (planare)"
+5 -5
View File
@@ -29,10 +29,10 @@ def test_offline_e_senza_finestre_del_browser():
def test_id_principali():
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
attesi = {"sorgente", "avvia", "ferma", "scatta", "grezzo", "mirinoCasella", "immagine",
attesi = {"sorgente", "avvia", "ferma", "scatta", "mirinoCasella", "immagine",
"nitidezza", "nitidezzaMax", "saturati", "media", "dimensioni",
"parametri", "salvaParametri", "quadrato", "acquisisci", "scegliFile", "file",
"salva", "stato", "statoSorgente"} # #91: carica/elimina sono ora per riga, nell'elenco
"parametri", "salvaParametri", "quadrato", "acquisisci", "caricaImmagine", "fileImmagine",
"mostraCal", "salva", "stato", "statoSorgente"} # #91: carica/elimina sono ora per riga; #92: file e calibrata nell'Acquisizione
assert attesi <= ids, attesi - ids
# ogni id usato dal JS con $("...") esiste nel markup
usati = set(re.findall(r'\$\("([^"]+)"\)', PAGINA))
@@ -41,8 +41,8 @@ def test_id_principali():
def test_sezioni_e_testi_italiani():
for testo in ("Sorgente", "Anteprima", "Parametri camera", "Calibrazione",
"Salva come predefiniti", "Scegli immagine...", "Questa sorgente non ha parametri regolabili",
"Immagine grezza (senza correzione)"):
"Salva come predefiniti", "Carica immagine da file...", "Questa sorgente non ha parametri regolabili",
"Mostra immagine calibrata"): # #92
assert testo in PAGINA, testo
assert "it-IT" in PAGINA # numeri con la virgola
assert "Choose File" not in PAGINA
+51 -14
View File
@@ -1,4 +1,4 @@
"""Test della pagina a due tab (#91): contenuto statico di PAGINA, senza servizio."""
"""Test della pagina a due tab (#91, #92): contenuto statico di PAGINA, senza servizio."""
import importlib.util
import re
@@ -20,37 +20,58 @@ def test_due_tab_con_ruolo_e_selezione():
assert '"tmf.acq.tab"' in PAGINA # tab scelta ricordata (via memoria(), protetta)
def test_anteprima_fuori_dai_pannelli():
# l'anteprima sta nella colonna sinistra, prima dei due pannelli: visibile in entrambe le tab
i_ant = PAGINA.index('id="sezioneAnteprima"')
def test_sorgente_in_cima_poi_riquadri():
# #92: Sorgente, anteprima e parametri stanno nel pannello Acquisizione; Sorgente prima dei riquadri
i_acq = PAGINA.index('id="pannelloAcq"')
i_cal = PAGINA.index('id="pannelloCal"')
assert i_ant < i_acq < i_cal
# Sorgente e Parametri nel pannello Acquisizione, la calibrazione in quello Calibrazione
assert i_acq < PAGINA.index('id="sezioneSorgente"') < PAGINA.index('id="sezioneParametri"') < i_cal
i_sorg = PAGINA.index('id="sezioneSorgente"')
i_ant = PAGINA.index('id="sezioneAnteprima"')
i_riq = PAGINA.index('id="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="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)
assert "grezzo: true" in PAGINA
def test_id_del_pannello_calibrazione():
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
attesi = {"calSorgente", "origSorgente", "origFile", "scegliFile", "file", "quadrato", "acquisisci",
"nomeCal", "salva", "msgSalva", "calibrazioni", "msgElenco", "stato", "risultato", "overlay"}
attesi = {"calSorgente", "quadrato", "acquisisci", "nomeCal", "salva", "msgSalva", "calibrazioni",
"msgElenco", "stato", "risultato", "overlay",
"caricaImmagine", "fileImmagine", "rimuoviImmagine", "riquadroCal", "immagineCal", "mirinoCal"}
assert attesi <= ids, attesi - ids
usati = set(re.findall(r'\$\("([^"]+)"\)', PAGINA))
assert usati <= ids, usati - ids
assert len(re.findall(r'id="([^"]+)"', PAGINA)) == len(ids) # nessun id doppio
def test_calibrazione_senza_file_ne_radio():
# #92: l'immagine da file e' una sorgente di acquisizione, non un'opzione della calibrazione
for sparito in ("origFile", "origSorgente", "scegliFile", "nomeFile", 'type="radio"', "Immagine da file", "Sorgente selezionata"):
assert sparito not in PAGINA, sparito
# l'unico input file e' nella sezione Sorgente, in cima alla tab Acquisizione
assert PAGINA.count('type="file"') == 1
assert PAGINA.index('type="file"') < PAGINA.index('id="pannelloCal"')
assert "immagine grezza dalla tab Acquisizione" in PAGINA
# il calcolo manda solo la sorgente (e il lato), mai il file
assert 'f.append("source", sorgente)' in PAGINA and 'f.append("image"' not in PAGINA.split("async function calcola")[1].split("async function salvaNome")[0]
def test_testi_del_pannello():
for testo in ("Sorgente selezionata", "Immagine da file", "Scegli immagine...", "Calcola",
"Calibrazioni salvate", "si cambia nella tab Acquisizione", "risoluzione diversa",
"Carica", "Elimina", "Scollega", "Sovrascrivi «", "Clic ancora", "Nome"):
for testo in ("Camera o immagine", "Carica immagine da file...", "Rimuovi immagine", "Calcola",
"Calibrazioni salvate", "risoluzione diversa", "Calibrata",
"Carica", "Elimina", "Scollega", "Sovrascrivi «", "Clic ancora", "Nome",
"calibrata: ", "non calibrata"):
assert testo in PAGINA, testo
def test_rotte_del_contratto():
for rotta in ("/camera/calibrazioni", "/camera/calibrazione-attiva", "/camera/calibrate",
"/camera/calibration?source="):
"/camera/calibration?source=", "/camera/immagini", "/camera/immagini?nome=",
"/camera/anteprima/calibrata"): # #92
assert '"' + rotta in PAGINA, rotta
for parola in ("calcolo_id", "sovrascrivi", "method: \"PUT\"", "method: \"DELETE\"", "X-Larghezza", "X-Altezza"):
assert parola in PAGINA, parola
@@ -65,7 +86,7 @@ def test_nessuna_finestra_del_browser_e_offline():
def test_acquisizione_di_87_90_conservata():
# anteprima, indicatori, mirino, grezzo, parametri camera e ciclo a 100 ms restano
for parola in ("Parametri camera", "Immagine grezza (senza correzione)", "Mirino centrale",
for parola in ("Parametri camera", "Mirino centrale",
"Math.max(100 - (Date.now() - t0), 0)", "/camera/anteprima", "/camera/parametri"):
assert parola in PAGINA, parola
@@ -75,3 +96,19 @@ def test_pagina_stretta():
# #91: elenco a schede, niente tabella larga ne' scorrimento orizzontale
assert 'id="calibrazioni" class="elenco"' in PAGINA and "overflow-x" not in PAGINA
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
calcola = PAGINA.split("async function calcola")[1].split("async function salvaNome")[0]
assert "fermaAnteprima();" in calcola and "while (A.inVolo)" in calcola
def test_calibrata_dopo_anteprima_e_dopo_carica():
# #92: la calibrata segue ogni anteprima grezza riuscita e si aggiorna dopo Carica/Scollega; 404/409 al posto dell'immagine
assert "await aggiornaCalibrata(lato);" in PAGINA
assert PAGINA.count("aggiornaCalibrata();") >= 3
assert "messaggioCalibrata(await dettaglio(r))" in PAGINA
assert 'decodeURIComponent(r.headers.get("X-Calibrazione")' in PAGINA