Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 67337fa232 | |||
| b98d1f15f4 | |||
| 552f96bb47 | |||
| 71daab184f | |||
| 9beaf2ed67 | |||
| 800ec41254 | |||
| 9c45a51770 | |||
| 4a48af2ae3 | |||
| b879563738 | |||
| f504c86491 |
+539
-161
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -41,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;
|
||||
@@ -61,10 +68,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, #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); }
|
||||
.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 +110,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 +127,169 @@ 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"> </div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="msgAnteprima" class="messaggio"></div>
|
||||
</section>
|
||||
</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>
|
||||
</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"> Attiva calibrazione</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>
|
||||
<!-- #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">
|
||||
<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"> </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>
|
||||
<!-- #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>
|
||||
<!-- #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>
|
||||
</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 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">
|
||||
<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>
|
||||
<!-- #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>
|
||||
<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>
|
||||
|
||||
@@ -243,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 };
|
||||
@@ -289,32 +349,123 @@ async function dettaglio(risposta) {
|
||||
|
||||
// ---- Sorgente
|
||||
|
||||
async function caricaSorgenti() {
|
||||
// #92: scegli = nome da selezionare (immagine appena caricata); senza, l'ultima scelta ricordata
|
||||
// #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");
|
||||
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: 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 () {
|
||||
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();
|
||||
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");
|
||||
} 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; }
|
||||
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");
|
||||
}
|
||||
};
|
||||
|
||||
// #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();
|
||||
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", "");
|
||||
dire("msgElenco", ""); // #91
|
||||
aggiornaStato();
|
||||
@@ -324,7 +475,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: 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;
|
||||
@@ -345,9 +497,74 @@ 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, 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 + "»" : "") + (soloScala ? " — solo scala, nessuna correzione ottica" : ""); // #95: senza mappa il frame e' grezzo
|
||||
}
|
||||
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;
|
||||
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", {
|
||||
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) { // 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, r.headers.get("X-Raddrizzato") === "no"); // #95
|
||||
} catch (e) {
|
||||
if (n === A.seqCal) messaggioCalibrata("Servizio non raggiungibile.");
|
||||
}
|
||||
}
|
||||
|
||||
function aggiornaIndicatori(h) {
|
||||
@@ -372,8 +589,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 +597,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 +615,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");
|
||||
@@ -411,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;
|
||||
@@ -436,18 +656,83 @@ 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"; };
|
||||
$("mirinoCasella").onchange = function () { $("mirino").hidden = !$("mirinoCasella").checked; };
|
||||
// #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;
|
||||
$("riquadroCal").hidden = !si;
|
||||
$("riquadri").className = "riquadri" + (si ? " doppio" : "");
|
||||
if (si) aggiornaCalibrata();
|
||||
}
|
||||
$("mostraCal").onchange = mostraRiquadroCal;
|
||||
|
||||
// ---- Parametri camera (#87)
|
||||
|
||||
@@ -676,6 +961,8 @@ function scegliTab(nome, salva) {
|
||||
$("tabCal").tabIndex = cal ? 0 : -1;
|
||||
$("pannelloAcq").hidden = cal;
|
||||
$("pannelloCal").hidden = !cal;
|
||||
// #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
|
||||
}
|
||||
$("tabAcq").onclick = function () { scegliTab("acq", true); };
|
||||
@@ -692,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)"],
|
||||
@@ -748,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));
|
||||
@@ -755,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)");
|
||||
@@ -770,8 +1065,8 @@ async function aggiornaStato() {
|
||||
stato.appendChild(titolo);
|
||||
stato.appendChild(t);
|
||||
stato.appendChild(quando);
|
||||
$("statoSorgente").textContent = "calibrata: " + fmt(d.px_per_mm, 2) + " px/mm";
|
||||
if (d.square_mm) $("quadrato").value = fmtCasella(d.square_mm); // #87: parte dal lato gia' usato
|
||||
$("statoSorgente").textContent = "calibrata: " + (d.nome || "(senza nome)") + " · " + fmt(d.px_per_mm, 2) + " px/mm"; // #92
|
||||
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.";
|
||||
@@ -810,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";
|
||||
@@ -841,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(" · ");
|
||||
@@ -850,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);
|
||||
@@ -865,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");
|
||||
$("raddrizzato").textContent = $("grezzo").checked ? "non raddrizzata" : "raddrizzata dalla calibrazione"; // il prossimo frame conferma
|
||||
aggiornaStato();
|
||||
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");
|
||||
if (r.ok) $("raddrizzato").textContent = "non raddrizzata";
|
||||
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();
|
||||
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);
|
||||
@@ -927,25 +1264,20 @@ 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);
|
||||
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 (!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();
|
||||
@@ -953,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", "");
|
||||
@@ -963,7 +1295,6 @@ async function calcola() {
|
||||
} finally {
|
||||
$("acquisisci").disabled = false;
|
||||
$("salva").disabled = false;
|
||||
if (eraAttiva) avviaAnteprima();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1004,17 +1335,64 @@ 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(); };
|
||||
// #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>
|
||||
|
||||
+111
-16
@@ -29,6 +29,10 @@ Calibrazione a scacchiera (#83, logica in scripts/calibrazione.py).
|
||||
calibrated_at, attiva_su}], attiva: nome|null} (dalla piu' recente)
|
||||
POST /camera/calibrazioni json {calcolo_id, nome, sovrascrivi=false} -> 201
|
||||
metadati. 404 calcolo scaduto, 400 nome non valido, 409 nome gia' usato
|
||||
POST /camera/calibrazioni/manuale json {source, nome, mm_per_px, sovrascrivi=false}
|
||||
-> 201 metadati (#95: mm/px impostato a mano, metodo "manuale", nessuna
|
||||
mappa; non la attiva). 400 mm/px fuori 0.00001..100 o nome non valido,
|
||||
409 nome gia' usato; la risoluzione e' quella del frame tenuto o di un grab
|
||||
DELETE /camera/calibrazioni?nome=X -> 204 (toglie anche la mappa e la scollega
|
||||
da ogni sorgente) | 404
|
||||
PUT /camera/calibrazione-attiva json {source, nome}: legge un frame dalla
|
||||
@@ -84,10 +88,12 @@ 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
|
||||
@@ -110,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")
|
||||
@@ -154,6 +161,7 @@ def create_app(folder: Path | str | None = 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
|
||||
@@ -169,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())
|
||||
@@ -184,12 +216,9 @@ 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())
|
||||
# #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
|
||||
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}
|
||||
@@ -200,6 +229,7 @@ def create_app(folder: Path | str | None = None,
|
||||
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()
|
||||
@@ -345,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:
|
||||
@@ -352,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)
|
||||
@@ -374,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
|
||||
|
||||
@@ -406,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):
|
||||
@@ -508,9 +576,28 @@ 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."""
|
||||
"""#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)
|
||||
@@ -524,14 +611,22 @@ def create_app(folder: Path | str | None = None,
|
||||
if attiva is None:
|
||||
raise HTTPException(409, "nessuna calibrazione attiva su questa sorgente")
|
||||
mappa = archivio.leggi_mappa(nome)
|
||||
if mappa is None: # planare: ha solo scala, nessuna mappa da applicare
|
||||
raise HTTPException(409, f"la calibrazione «{attiva}» non ha una mappa di correzione (planare)")
|
||||
corretto = calibrazione.raddrizza(frame, mappa)
|
||||
if corretto is None: # risoluzione diversa da quella calibrata
|
||||
ch, cw = mappa.shape[:2]
|
||||
if mappa is None: # #95: senza mappa (manuale o planare) solo scala: frame grezzo, nessuna correzione
|
||||
dati = archivio.leggi(nome) or {}
|
||||
cw, ch = dati.get("image_width"), dati.get("image_height")
|
||||
alt, larg = frame.shape[:2]
|
||||
raise HTTPException(409, f"la calibrazione «{attiva}» è per immagini {cw}×{ch}, "
|
||||
f"il frame è {larg}×{alt}")
|
||||
if isinstance(cw, int) and isinstance(ch, int) and (cw, ch) != (larg, alt):
|
||||
raise HTTPException(409, f"la calibrazione «{attiva}» è per immagini {cw}×{ch}, "
|
||||
f"il frame è {larg}×{alt}")
|
||||
corretto, raddrizzato = frame, "no"
|
||||
else:
|
||||
corretto = calibrazione.raddrizza(frame, mappa)
|
||||
if corretto is None: # risoluzione diversa da quella calibrata
|
||||
ch, cw = mappa.shape[:2]
|
||||
alt, larg = frame.shape[:2]
|
||||
raise HTTPException(409, f"la calibrazione «{attiva}» è per immagini {cw}×{ch}, "
|
||||
f"il frame è {larg}×{alt}")
|
||||
raddrizzato = "si"
|
||||
alt, larg = corretto.shape[:2]
|
||||
if corretto.dtype != np.uint8: # il JPEG vuole 8 bit
|
||||
corretto = _grigio8(corretto)
|
||||
@@ -543,7 +638,7 @@ def create_app(folder: Path | str | None = None,
|
||||
if not ok:
|
||||
raise HTTPException(500, "codifica JPEG fallita")
|
||||
return Response(buf.tobytes(), media_type="image/jpeg", headers={
|
||||
"X-Raddrizzato": "si", "X-Larghezza": str(larg), "X-Altezza": str(alt),
|
||||
"X-Raddrizzato": raddrizzato, "X-Larghezza": str(larg), "X-Altezza": str(alt),
|
||||
"X-Calibrazione": urllib.parse.quote(attiva, safe="")})
|
||||
|
||||
@app.get("/camera/parametri")
|
||||
|
||||
@@ -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)")
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
@@ -106,6 +106,34 @@ def test_sources_con_immagini_e_senza_camera_file(client):
|
||||
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)
|
||||
@@ -254,10 +282,11 @@ def test_calibrata_risoluzione_diversa_409(client):
|
||||
assert r.json()["detail"] == "la calibrazione «Banco» è per immagini 640×480, il frame è 320×240"
|
||||
|
||||
|
||||
def test_calibrata_planare_senza_mappa_409(client):
|
||||
def test_calibrata_planare_senza_mappa_200_grezza(client):
|
||||
# #95: senza mappa (planare) non e' piu' 409: esce il frame grezzo, X-Raddrizzato: no
|
||||
_carica(client, PIANA, una_scacchiera())
|
||||
_calibra_e_attiva(client, PIANA, "Piana", cols="9", rows="6")
|
||||
assert _anteprima(client, PIANA).status_code == 200
|
||||
r = _calibrata(client, PIANA)
|
||||
assert r.status_code == 409
|
||||
assert r.json()["detail"] == "la calibrazione «Piana» non ha una mappa di correzione (planare)"
|
||||
assert r.status_code == 200
|
||||
assert r.headers["X-Raddrizzato"] == "no" and unquote(r.headers["X-Calibrazione"]) == "Piana"
|
||||
|
||||
@@ -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",
|
||||
"Attiva calibrazione"): # #92 #93
|
||||
assert testo in PAGINA, testo
|
||||
assert "it-IT" in PAGINA # numeri con la virgola
|
||||
assert "Choose File" not in PAGINA
|
||||
|
||||
@@ -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 "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)
|
||||
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,48 @@ 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_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 "A.inVolo) await pausa(50)" 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
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""MeasurementTec blueprint - recipe selection and measurement execution."""
|
||||
import json
|
||||
import urllib.parse
|
||||
|
||||
import requests as http_requests
|
||||
@@ -337,6 +338,14 @@ def task_execute(task_id: int):
|
||||
measurement_interval_minutes = recipe_resp.get("measurement_interval_minutes")
|
||||
allow_manual_input = bool(recipe_resp.get("allow_manual_input", True))
|
||||
|
||||
# #97: camera del task (acquisition_json) o della stazione; il modo di analisi
|
||||
# (impulsiva / in_linea) viene dal task. Solo i task visione ne hanno bisogno.
|
||||
camera = None
|
||||
camera_mode = "impulsiva"
|
||||
if task_resp.get("task_type") in ("camera_measure", "dxf_compare"):
|
||||
camera = _task_camera(task_resp, _current_station()[0])
|
||||
camera_mode = _task_mode(task_resp)
|
||||
|
||||
return render_template(
|
||||
"measure/task_execute.html",
|
||||
task=task_resp,
|
||||
@@ -345,7 +354,8 @@ def task_execute(task_id: int):
|
||||
all_task_ids=all_task_ids,
|
||||
measurement_interval_minutes=measurement_interval_minutes,
|
||||
allow_manual_input=allow_manual_input,
|
||||
camera_enabled=_station_camera(_current_station()[0]) is not None,
|
||||
camera_enabled=camera is not None,
|
||||
camera_mode=camera_mode,
|
||||
)
|
||||
|
||||
|
||||
@@ -1001,6 +1011,42 @@ def api_vision_execute():
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
# #97: analysis of a frame without saving anything (preview before «Registra»).
|
||||
@measure_bp.route("/api/vision/check", methods=["POST"])
|
||||
@login_required
|
||||
@role_required("MeasurementTec")
|
||||
def api_vision_check():
|
||||
"""Proxy: run the task's VisionSuite recipe on the image, nothing is saved."""
|
||||
image = request.files.get("image")
|
||||
task_id = request.form.get("task_id")
|
||||
if image is None or not task_id:
|
||||
return jsonify({
|
||||
"error": True,
|
||||
"detail": _("Dati mancanti: task_id e image sono obbligatori"),
|
||||
}), 400
|
||||
|
||||
form = {"task_id": task_id}
|
||||
if request.form.get("production_run_id"):
|
||||
form["production_run_id"] = request.form["production_run_id"]
|
||||
|
||||
resp = api_client.post(
|
||||
"/api/vision/check",
|
||||
data=form,
|
||||
files={"image": (
|
||||
image.filename or "capture.jpg",
|
||||
image.read(),
|
||||
image.mimetype or "application/octet-stream",
|
||||
)},
|
||||
)
|
||||
if resp.get("error"):
|
||||
status_code = resp.get("status_code", 500)
|
||||
body = {"error": True, "detail": resp.get("detail", _("Errore nell'esecuzione"))}
|
||||
if resp.get("session_expired"):
|
||||
body["session_expired"] = True
|
||||
return jsonify(body), status_code if status_code >= 400 else 500
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
def _station_camera(station_code: str | None) -> tuple[str, str] | None:
|
||||
"""(camera_url, camera_source) of the station, None when it has no camera.
|
||||
|
||||
@@ -1019,6 +1065,31 @@ def _station_camera(station_code: str | None) -> tuple[str, str] | None:
|
||||
return str(url), str(source)
|
||||
|
||||
|
||||
def _task_acquisition(task: dict | None) -> dict | None:
|
||||
"""#97: acquisition_json of a task as a dict, None when absent or unreadable."""
|
||||
acq = task.get("acquisition_json") if isinstance(task, dict) else None
|
||||
if isinstance(acq, str):
|
||||
try:
|
||||
acq = json.loads(acq)
|
||||
except ValueError:
|
||||
return None
|
||||
return acq if isinstance(acq, dict) else None
|
||||
|
||||
|
||||
def _task_camera(task: dict | None, station_code: str | None) -> tuple[str, str] | None:
|
||||
"""#97: camera of a task = its acquisition_json (url + source), else the station's."""
|
||||
acq = _task_acquisition(task)
|
||||
if acq and acq.get("camera_url") and acq.get("camera_source"):
|
||||
return str(acq["camera_url"]), str(acq["camera_source"])
|
||||
return _station_camera(station_code)
|
||||
|
||||
|
||||
def _task_mode(task: dict | None) -> str:
|
||||
"""#97: analysis mode of a task, «impulsiva» unless acquisition_json says «in_linea»."""
|
||||
acq = _task_acquisition(task)
|
||||
return "in_linea" if acq and acq.get("modo") == "in_linea" else "impulsiva"
|
||||
|
||||
|
||||
# #72: the browser asks the station's camera service (vs-gui) for a frame; the
|
||||
# service address is a server-side matter, so Flask relays and the PNG goes back as is.
|
||||
@measure_bp.route("/api/vision/grab", methods=["POST"])
|
||||
@@ -1026,7 +1097,16 @@ def _station_camera(station_code: str | None) -> tuple[str, str] | None:
|
||||
@role_required("MeasurementTec")
|
||||
def api_vision_grab():
|
||||
"""Proxy: grab one frame from the current station's camera, answer a PNG."""
|
||||
camera = _station_camera(_current_station()[0])
|
||||
# #97: con task_id (form o json) la camera e' quella del task, altrimenti della stazione
|
||||
payload = request.get_json(silent=True) or {}
|
||||
task_id = request.form.get("task_id") or payload.get("task_id")
|
||||
task = None
|
||||
if task_id:
|
||||
try:
|
||||
task = api_client.get(f"/api/tasks/{int(task_id)}")
|
||||
except (TypeError, ValueError):
|
||||
task = None
|
||||
camera = _task_camera(task, _current_station()[0])
|
||||
if camera is None:
|
||||
return jsonify({
|
||||
"error": True,
|
||||
|
||||
@@ -11,6 +11,13 @@
|
||||
* taskType, subtasks, labels.
|
||||
* Run context (lot, serial, production run) is read lazily through `ctx()`,
|
||||
* which the template binds to the parent task component.
|
||||
*
|
||||
* #97: with a camera (cameraAvailable, the task's or the station's) the task opens
|
||||
* on a frame already grabbed and analysed (/vision/check, nothing saved: «preview»).
|
||||
* modo «impulsiva»: «Acquisisci» redoes grab+check. modo «in_linea»: continuous
|
||||
* loop grab+check, one request at a time, standing still while paused / page hidden
|
||||
* / leaving / saving. «Registra» saves the LAST frame shown through /vision/execute.
|
||||
* Extra config: cameraAvailable, modo, checkUrl.
|
||||
*/
|
||||
function visionExecute() {
|
||||
// Overlay colour per role (VisionSuite roles: reference, roi, found).
|
||||
@@ -24,6 +31,125 @@ function visionExecute() {
|
||||
imageSrc: '', // object URL of the acquired image (shown under the overlay)
|
||||
ctx: () => ({}), // replaced by the template: {lot, serial, runId}
|
||||
|
||||
// #97: preview / loop state
|
||||
preview: false, // result on screen is an analysis only, not saved
|
||||
saved: false, // «Registra» done: the saved result replaces the preview
|
||||
lastBlob: null, // last frame shown, the one «Registra» saves
|
||||
paused: false, // operator paused the in-line loop
|
||||
registering: false, // «Registra» in progress: the loop stands still
|
||||
leaving: false, // page is being left: no more requests
|
||||
looping: false, // an in-line loop is running
|
||||
gen: 0, // bumped by «Registra»: answers of older requests are dropped
|
||||
|
||||
// #97: start on a frame already acquired and analysed; hook visibility and exit.
|
||||
init() {
|
||||
if (!this.hasCamera) return;
|
||||
document.addEventListener('visibilitychange', () => { if (!document.hidden) this.kick(); });
|
||||
const leave = () => { this.leaving = true; };
|
||||
window.addEventListener('pagehide', leave);
|
||||
window.addEventListener('beforeunload', leave);
|
||||
// After x-init has bound ctx(): in-line starts the loop, impulsive grabs once.
|
||||
this.$nextTick(() => { if (this.inLinea) this.loop(); else this.grab(); });
|
||||
},
|
||||
|
||||
get hasCamera() { return !!(window.__visionCfg || {}).cameraAvailable; },
|
||||
get inLinea() { return this.hasCamera && (window.__visionCfg || {}).modo === 'in_linea'; },
|
||||
// «Registra» needs a frame on screen that is not saved yet
|
||||
get canRecord() { return !!this.lastBlob && !this.saved && !this.registering; },
|
||||
|
||||
shouldRun() {
|
||||
return this.inLinea && !this.paused && !this.saved && !this.registering
|
||||
&& !this.leaving && !document.hidden;
|
||||
},
|
||||
|
||||
// Page visible again: restart the loop unless the operator paused it.
|
||||
kick() { if (this.shouldRun() && !this.looping) this.loop(); },
|
||||
|
||||
// In-line loop: one request at a time, at least 300 ms between two of them
|
||||
// (longer after an error, so a dead camera is not hammered).
|
||||
async loop() {
|
||||
if (this.looping) return;
|
||||
this.looping = true;
|
||||
try {
|
||||
while (this.shouldRun()) {
|
||||
const ok = await this.grab();
|
||||
await new Promise((r) => setTimeout(r, ok ? 300 : 2000));
|
||||
}
|
||||
} finally {
|
||||
this.looping = false;
|
||||
}
|
||||
},
|
||||
|
||||
// «Pausa» / «Riprendi»; after «Registra» only «Riprendi» restarts the loop.
|
||||
togglePause() {
|
||||
if (this.paused || this.saved) {
|
||||
this.paused = false;
|
||||
this.saved = false;
|
||||
this.loop();
|
||||
} else {
|
||||
this.paused = true;
|
||||
}
|
||||
},
|
||||
|
||||
// Analysis only (/vision/check): the frame becomes the preview, nothing is saved.
|
||||
async analyze(blob) {
|
||||
const cfg = window.__visionCfg || {};
|
||||
if (!blob || !cfg.checkUrl) return false;
|
||||
const gen = this.gen;
|
||||
this.busy = true;
|
||||
this.error = '';
|
||||
try {
|
||||
const c = this.ctx() || {};
|
||||
const fd = new FormData();
|
||||
fd.append('task_id', cfg.taskId);
|
||||
fd.append('image', blob, blob.name || 'capture.jpg');
|
||||
if (c.runId) fd.append('production_run_id', c.runId);
|
||||
const csrf = document.querySelector('meta[name=csrf-token]')?.content || '';
|
||||
const resp = await fetch(cfg.checkUrl, {
|
||||
method: 'POST', headers: { 'X-CSRFToken': csrf }, body: fd,
|
||||
});
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
if (gen !== this.gen) return false; // «Registra» took over
|
||||
if (!resp.ok || body.error) {
|
||||
this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
||||
return false;
|
||||
}
|
||||
if (this.imageSrc) URL.revokeObjectURL(this.imageSrc);
|
||||
this.imageSrc = URL.createObjectURL(blob);
|
||||
this.lastBlob = blob;
|
||||
this.result = body;
|
||||
this.preview = true;
|
||||
this.saved = false;
|
||||
this.$nextTick(() => this.drawOverlay());
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (gen === this.gen) this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
||||
return false;
|
||||
} finally {
|
||||
if (gen === this.gen) this.busy = false;
|
||||
}
|
||||
},
|
||||
|
||||
// «Registra»: save the LAST frame shown with the usual /vision/execute.
|
||||
async record() {
|
||||
if (!this.canRecord) return;
|
||||
const blob = this.lastBlob;
|
||||
this.registering = true;
|
||||
this.gen++; // drop whatever request is still in flight
|
||||
this.busy = false;
|
||||
this.grabbing = false;
|
||||
try {
|
||||
await this.acquire(blob);
|
||||
if (!this.error && this.result && this.result.vision_result_id) {
|
||||
this.preview = false;
|
||||
this.saved = true;
|
||||
}
|
||||
} finally {
|
||||
this.registering = false;
|
||||
this.kick(); // in-line: stays still while saved, restarts if the save failed
|
||||
}
|
||||
},
|
||||
|
||||
// Entry point shared with the future agent camera.
|
||||
async acquire(blob) {
|
||||
if (!blob || this.busy) return;
|
||||
@@ -64,37 +190,52 @@ function visionExecute() {
|
||||
|
||||
// Station camera (#72): the Flask proxy answers a PNG, which goes through
|
||||
// acquire() like a picked file. Errors show as the execute ones do.
|
||||
// #97: sends task_id (the task's camera wins over the station's); with a camera
|
||||
// the frame goes to the preview analysis. Returns true when the frame was analysed.
|
||||
async grab() {
|
||||
const cfg = window.__visionCfg || {};
|
||||
if (this.busy || this.grabbing || !cfg.grabUrl) return;
|
||||
if (this.busy || this.grabbing || this.registering || !cfg.grabUrl) return false;
|
||||
const gen = this.gen;
|
||||
this.grabbing = true;
|
||||
this.error = '';
|
||||
let blob = null;
|
||||
try {
|
||||
const csrf = document.querySelector('meta[name=csrf-token]')?.content || '';
|
||||
const fd = new FormData();
|
||||
fd.append('task_id', cfg.taskId);
|
||||
const resp = await fetch(cfg.grabUrl, {
|
||||
method: 'POST', headers: { 'X-CSRFToken': csrf },
|
||||
method: 'POST', headers: { 'X-CSRFToken': csrf }, body: fd,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
||||
return;
|
||||
if (gen === this.gen) this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
||||
return false;
|
||||
}
|
||||
blob = await resp.blob();
|
||||
} catch (e) {
|
||||
this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
||||
return;
|
||||
if (gen === this.gen) this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
||||
return false;
|
||||
} finally {
|
||||
this.grabbing = false;
|
||||
if (gen === this.gen) this.grabbing = false;
|
||||
}
|
||||
if (blob) await this.acquire(blob);
|
||||
if (!blob || gen !== this.gen) return false;
|
||||
if (this.hasCamera) return await this.analyze(blob);
|
||||
await this.acquire(blob);
|
||||
return !this.error;
|
||||
},
|
||||
|
||||
// Input change: take the file and allow picking the same one again.
|
||||
// #97: with a camera the file is a preview too (the loop pauses), «Registra» saves it.
|
||||
onPick(ev) {
|
||||
const f = ev.target.files && ev.target.files[0];
|
||||
ev.target.value = '';
|
||||
if (f) this.acquire(f);
|
||||
if (!f) return;
|
||||
if (this.hasCamera) {
|
||||
if (this.inLinea) this.paused = true;
|
||||
this.analyze(f);
|
||||
} else {
|
||||
this.acquire(f);
|
||||
}
|
||||
},
|
||||
|
||||
// Image shown: the saved one from the server when available, else the local one.
|
||||
|
||||
@@ -534,9 +534,10 @@
|
||||
<div x-show="isVisionTask" x-cloak
|
||||
x-data="visionExecute()"
|
||||
x-init="ctx = () => ({ lot: lotNumber, serial: serialNumber, runId: productionRun ? productionRun.id : null })"
|
||||
class="flex-1 overflow-auto p-3 flex flex-col lg:flex-row gap-3">
|
||||
class="flex-1 min-h-0 overflow-hidden p-3 flex flex-col lg:flex-row gap-3">
|
||||
{# #96: il pannello non scorre mai (overflow-hidden, min-h-0): l'immagine sta in fit nello spazio che resta #}
|
||||
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-2">
|
||||
<div class="flex-1 min-w-0 min-h-0 flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
{# On a tablet this opens the camera; on a PC it picks a file. «Riprova» reuses the same input. #}
|
||||
<label class="btn btn-primary text-sm gap-1.5 cursor-pointer" :class="busy ? 'opacity-50 pointer-events-none' : ''">
|
||||
@@ -545,12 +546,29 @@
|
||||
@change="onPick($event)" :disabled="busy">
|
||||
</label>
|
||||
{% if camera_enabled %}
|
||||
{# #72: frame dalla camera della stazione, stessa strada di acquire() #}
|
||||
{# #97: camera del task (o della stazione). Impulsiva: «Acquisisci» rifa grab+check; in linea: «Pausa»/«Riprendi» sul ciclo #}
|
||||
{% if camera_mode == 'in_linea' %}
|
||||
<button type="button" class="btn btn-secondary text-sm gap-1.5" @click="togglePause()"
|
||||
:class="registering ? 'opacity-50 pointer-events-none' : ''"
|
||||
:disabled="registering">
|
||||
<span x-text="(paused || saved) ? '{{ _('Riprendi') }}' : '{{ _('Pausa') }}'"></span>
|
||||
</button>
|
||||
{% else %}
|
||||
{# #72: frame dalla camera, stessa strada di acquire(); #97: ora grab + analisi senza salvare #}
|
||||
<button type="button" class="btn btn-secondary text-sm gap-1.5" @click="grab()"
|
||||
:class="(busy || grabbing) ? 'opacity-50 pointer-events-none' : ''"
|
||||
:disabled="busy || grabbing">
|
||||
<span>{{ _('Acquisisci dalla camera') }}</span>
|
||||
<span>{{ _('Acquisisci') }}</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
{# #97: salva l'ULTIMO frame mostrato (execute); fino ad allora e' solo un'anteprima #}
|
||||
<button type="button" class="btn btn-primary text-sm gap-1.5" @click="record()"
|
||||
:class="canRecord ? '' : 'opacity-50 pointer-events-none'"
|
||||
:disabled="!canRecord">
|
||||
<span>{{ _('Registra') }}</span>
|
||||
</button>
|
||||
<span x-show="preview && !saved" x-cloak
|
||||
class="inline-block px-2 py-0.5 rounded text-xs font-bold bg-amber-50 dark:bg-amber-900/20 border border-amber-300 dark:border-amber-700">{{ _('Anteprima, non registrata') }}</span>
|
||||
<span x-show="grabbing" class="text-sm text-[var(--text-muted)]">{{ _('Acquisizione in corso...') }}</span>
|
||||
{% endif %}
|
||||
<span x-show="busy" class="text-sm text-[var(--text-muted)]">{{ _('Elaborazione in corso...') }}</span>
|
||||
@@ -558,16 +576,18 @@
|
||||
|
||||
<div x-show="error" class="p-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-sm text-measure-fail" x-text="error"></div>
|
||||
|
||||
<div x-show="shownImage" class="relative border border-[var(--border-color)] rounded-lg overflow-hidden bg-black/5">
|
||||
<img x-ref="img" :src="shownImage" @load="drawOverlay()" alt="" class="block w-full h-auto">
|
||||
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full pointer-events-none"></canvas>
|
||||
{# #96: riquadro flex-1 min-h-0; img e canvas object-contain sovrapposti (il canvas ha la stessa risoluzione naturale dell'immagine, quindi l'overlay resta allineato) #}
|
||||
<div x-show="shownImage" class="relative flex-1 min-h-0 border border-[var(--border-color)] rounded-lg overflow-hidden bg-black/5">
|
||||
<img x-ref="img" :src="shownImage" @load="drawOverlay()" alt="" class="absolute inset-0 w-full h-full object-contain">
|
||||
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full object-contain pointer-events-none"></canvas>
|
||||
</div>
|
||||
<div x-show="!shownImage" class="text-sm text-[var(--text-muted)]">
|
||||
{{ _('Nessuna immagine acquisita: premi «Acquisisci immagine».') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lg:w-80 shrink-0 flex flex-col gap-2" x-show="result">
|
||||
{# #96: colonna esito con scorrimento proprio (solo con molte quote); sotto lg max 40% dell'altezza #}
|
||||
<div class="lg:w-80 shrink-0 min-h-0 overflow-y-auto max-h-[40%] lg:max-h-none flex flex-col gap-2" x-show="result">
|
||||
<div class="text-sm font-bold text-[var(--text-primary)]">
|
||||
{{ _('Esito') }}: <span x-text="(result && result.outcome) || '-'"></span>
|
||||
</div>
|
||||
@@ -1146,6 +1166,9 @@
|
||||
window.__visionCfg = {
|
||||
executeUrl: '{{ url_for("measure.api_vision_execute") }}',
|
||||
grabUrl: '{{ url_for("measure.api_vision_grab") }}',
|
||||
checkUrl: '{{ url_for("measure.api_vision_check") }}', {# #97: analisi senza salvare #}
|
||||
cameraAvailable: {{ (camera_enabled|default(false))|tojson }},
|
||||
modo: {{ (camera_mode|default('impulsiva'))|tojson }},
|
||||
imageUrlTpl: '{{ url_for("measure.api_vision_result_image", result_id=0) }}',
|
||||
taskId: {{ task.id|tojson }},
|
||||
taskType: {{ (task.task_type or '')|tojson }},
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
"""gitea:Adriano/TieMeasureFlow#97 - operator: image already analysed on entry.
|
||||
|
||||
The backend (POST /api/vision/check, task.acquisition_json) is built elsewhere:
|
||||
api_client and requests are mocked here with the contract's shapes.
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from tests.conftest import api_get_router
|
||||
from tests.test_measure_camera_grab import (
|
||||
PNG, RECIPE, STATION, TASK, _fake, _station,
|
||||
)
|
||||
|
||||
ACQ = {"camera_url": "http://acq-host:9000", "camera_source": "camera:601020", "modo": "in_linea"}
|
||||
|
||||
|
||||
def _task_with(acq):
|
||||
return {**TASK, "acquisition_json": acq}
|
||||
|
||||
|
||||
# --- grab: the camera of the task, else the station's -------------------------
|
||||
def test_grab_with_task_id_uses_task_camera(logged_in_client, mock_api_client, monkeypatch):
|
||||
_station(mock_api_client, monkeypatch, STATION)
|
||||
mock_api_client.get.side_effect = api_get_router({"/api/tasks/12": _task_with(ACQ)})
|
||||
with patch("blueprints.measure.http_requests.post", return_value=_fake()) as post:
|
||||
resp = logged_in_client.post("/measure/api/vision/grab", data={"task_id": "12"})
|
||||
assert resp.status_code == 200 and resp.data == PNG
|
||||
args, kwargs = post.call_args
|
||||
assert args[0] == "http://acq-host:9000/camera/grab"
|
||||
assert kwargs["json"]["source"] == "camera:601020"
|
||||
mock_api_client.get_station_by_code.assert_not_called()
|
||||
|
||||
|
||||
def test_grab_task_id_in_json_works_too(logged_in_client, mock_api_client, monkeypatch):
|
||||
_station(mock_api_client, monkeypatch, STATION)
|
||||
mock_api_client.get.side_effect = api_get_router({"/api/tasks/12": _task_with(ACQ)})
|
||||
with patch("blueprints.measure.http_requests.post", return_value=_fake()) as post:
|
||||
resp = logged_in_client.post("/measure/api/vision/grab", json={"task_id": 12})
|
||||
assert resp.status_code == 200
|
||||
assert post.call_args[0][0] == "http://acq-host:9000/camera/grab"
|
||||
|
||||
|
||||
def test_grab_task_without_acquisition_uses_station(logged_in_client, mock_api_client, monkeypatch):
|
||||
_station(mock_api_client, monkeypatch, STATION)
|
||||
mock_api_client.get.side_effect = api_get_router({"/api/tasks/12": _task_with(None)})
|
||||
with patch("blueprints.measure.http_requests.post", return_value=_fake()) as post:
|
||||
resp = logged_in_client.post("/measure/api/vision/grab", data={"task_id": "12"})
|
||||
assert resp.status_code == 200
|
||||
args, kwargs = post.call_args
|
||||
assert args[0] == "http://host.docker.internal:8765/camera/grab"
|
||||
assert kwargs["json"]["source"] == "camera:folder"
|
||||
|
||||
|
||||
def test_grab_task_camera_wins_even_if_station_has_none(logged_in_client, mock_api_client, monkeypatch):
|
||||
_station(mock_api_client, monkeypatch, {**STATION, "camera_url": None})
|
||||
mock_api_client.get.side_effect = api_get_router({"/api/tasks/12": _task_with(ACQ)})
|
||||
with patch("blueprints.measure.http_requests.post", return_value=_fake()) as post:
|
||||
resp = logged_in_client.post("/measure/api/vision/grab", data={"task_id": "12"})
|
||||
assert resp.status_code == 200
|
||||
assert post.call_args[0][0] == "http://acq-host:9000/camera/grab"
|
||||
|
||||
|
||||
def test_grab_task_lookup_error_falls_back_to_station(logged_in_client, mock_api_client, monkeypatch):
|
||||
_station(mock_api_client, monkeypatch, STATION)
|
||||
mock_api_client.get.side_effect = api_get_router(
|
||||
{"/api/tasks/12": {"error": True, "status_code": 404, "detail": "x"}})
|
||||
with patch("blueprints.measure.http_requests.post", return_value=_fake()) as post:
|
||||
resp = logged_in_client.post("/measure/api/vision/grab", data={"task_id": "12"})
|
||||
assert resp.status_code == 200
|
||||
assert post.call_args[0][0] == "http://host.docker.internal:8765/camera/grab"
|
||||
|
||||
|
||||
def test_grab_acquisition_as_json_string(logged_in_client, mock_api_client, monkeypatch):
|
||||
_station(mock_api_client, monkeypatch, {**STATION, "camera_url": None})
|
||||
mock_api_client.get.side_effect = api_get_router(
|
||||
{"/api/tasks/12": _task_with(json.dumps(ACQ))})
|
||||
with patch("blueprints.measure.http_requests.post", return_value=_fake()) as post:
|
||||
resp = logged_in_client.post("/measure/api/vision/grab", data={"task_id": "12"})
|
||||
assert resp.status_code == 200
|
||||
assert post.call_args[0][0] == "http://acq-host:9000/camera/grab"
|
||||
|
||||
|
||||
# --- check proxy ---------------------------------------------------------------
|
||||
CHECK_OK = {
|
||||
"outcome": "ok", "overlay": [], "failures": [], "calibration": "ok",
|
||||
"measurements": [{"subtask_id": 1, "value": 9.9, "pass_fail": "pass"}],
|
||||
}
|
||||
|
||||
|
||||
def test_check_proxy_forwards_multipart(logged_in_client, mock_api_client):
|
||||
mock_api_client.post.return_value = CHECK_OK
|
||||
resp = logged_in_client.post(
|
||||
"/measure/api/vision/check",
|
||||
data={
|
||||
"task_id": "12", "production_run_id": "4",
|
||||
"image": (io.BytesIO(b"\xff\xd8\xff fake"), "shot.jpg", "image/jpeg"),
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["measurements"][0]["pass_fail"] == "pass"
|
||||
args, kwargs = mock_api_client.post.call_args
|
||||
assert args[0] == "/api/vision/check"
|
||||
assert kwargs["data"] == {"task_id": "12", "production_run_id": "4"}
|
||||
name, content, mime = kwargs["files"]["image"]
|
||||
assert name == "shot.jpg" and mime == "image/jpeg" and content.startswith(b"\xff\xd8")
|
||||
|
||||
|
||||
def test_check_proxy_without_run_leaves_it_out(logged_in_client, mock_api_client):
|
||||
mock_api_client.post.return_value = CHECK_OK
|
||||
logged_in_client.post(
|
||||
"/measure/api/vision/check",
|
||||
data={"task_id": "12", "image": (io.BytesIO(b"x"), "a.jpg", "image/jpeg")},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
assert mock_api_client.post.call_args[1]["data"] == {"task_id": "12"}
|
||||
|
||||
|
||||
def test_check_proxy_requires_image(logged_in_client, mock_api_client):
|
||||
resp = logged_in_client.post("/measure/api/vision/check", data={"task_id": "12"})
|
||||
assert resp.status_code == 400
|
||||
mock_api_client.post.assert_not_called()
|
||||
|
||||
|
||||
def test_check_proxy_relays_backend_error(logged_in_client, mock_api_client):
|
||||
mock_api_client.post.return_value = {"error": True, "status_code": 422, "detail": "nessun grafo"}
|
||||
resp = logged_in_client.post(
|
||||
"/measure/api/vision/check",
|
||||
data={"task_id": "12", "image": (io.BytesIO(b"x"), "a.jpg", "image/jpeg")},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert resp.get_json()["detail"] == "nessun grafo"
|
||||
|
||||
|
||||
def test_check_requires_login(client):
|
||||
resp = client.post("/measure/api/vision/check")
|
||||
assert resp.status_code in (302, 401)
|
||||
|
||||
|
||||
# --- execute page: buttons and config -------------------------------------------
|
||||
def _page(logged_in_client, mock_api_client, monkeypatch, station, task):
|
||||
_station(mock_api_client, monkeypatch, station)
|
||||
mock_api_client.get_station_recipes.return_value = {"items": [{"id": 3}]}
|
||||
mock_api_client.get.side_effect = api_get_router({
|
||||
"/api/tasks/12": task,
|
||||
"/api/recipes/3/tasks": [{"id": 12, "order_index": 0}],
|
||||
"/api/recipes/3": RECIPE,
|
||||
})
|
||||
return logged_in_client.get("/measure/execute/12").get_data(as_text=True)
|
||||
|
||||
|
||||
def test_page_impulsive_has_acquire_and_record(logged_in_client, mock_api_client, monkeypatch):
|
||||
html = _page(logged_in_client, mock_api_client, monkeypatch, STATION, TASK)
|
||||
assert 'cameraAvailable: true' in html
|
||||
assert 'modo: "impulsiva"' in html
|
||||
assert "/measure/api/vision/check" in html
|
||||
assert "grab()" in html and "record()" in html
|
||||
assert "togglePause()" not in html
|
||||
assert "Anteprima, non registrata" in html
|
||||
|
||||
|
||||
def test_page_in_line_has_pause_and_record(logged_in_client, mock_api_client, monkeypatch):
|
||||
html = _page(logged_in_client, mock_api_client, monkeypatch, STATION, _task_with(ACQ))
|
||||
assert 'modo: "in_linea"' in html
|
||||
assert "togglePause()" in html and "record()" in html
|
||||
assert "grab()" not in html # the loop grabs by itself
|
||||
|
||||
|
||||
def test_page_task_camera_enables_it_without_station_camera(logged_in_client, mock_api_client, monkeypatch):
|
||||
html = _page(logged_in_client, mock_api_client, monkeypatch,
|
||||
{**STATION, "camera_url": None}, _task_with({**ACQ, "modo": "impulsiva"}))
|
||||
assert 'cameraAvailable: true' in html
|
||||
assert "grab()" in html
|
||||
|
||||
|
||||
def test_page_without_camera_is_as_before(logged_in_client, mock_api_client, monkeypatch):
|
||||
html = _page(logged_in_client, mock_api_client, monkeypatch,
|
||||
{**STATION, "camera_url": None}, TASK)
|
||||
assert 'cameraAvailable: false' in html
|
||||
assert "record()" not in html and "togglePause()" not in html and "grab()" not in html
|
||||
@@ -0,0 +1,54 @@
|
||||
"""gitea:Adriano/TieMeasureFlow#96 - l'immagine del task visione sta in fit, senza scorrimento.
|
||||
|
||||
Controllo sulle classi del template: pannello overflow-hidden, img/canvas
|
||||
object-contain sovrapposti, nessun h-auto sull'immagine (era la causa dello scorrimento).
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
TEMPLATE = Path(__file__).resolve().parents[1] / "templates" / "measure" / "task_execute.html"
|
||||
|
||||
|
||||
def _panel():
|
||||
src = TEMPLATE.read_text(encoding="utf-8")
|
||||
start = src.rindex("<div", 0, src.index('x-data="visionExecute()"'))
|
||||
end = src.index("MEASUREMENT TIMER BANNER")
|
||||
return src[start:end]
|
||||
|
||||
|
||||
def _tag_with(panel, marker):
|
||||
# Il tag (aperto) che contiene il marker; i '>' dentro le virgolette (es. "=>") non lo chiudono.
|
||||
pos = panel.index(marker)
|
||||
i = panel.rindex("<", 0, pos)
|
||||
quote = None
|
||||
for j in range(i, len(panel)):
|
||||
c = panel[j]
|
||||
if quote:
|
||||
quote = None if c == quote else quote
|
||||
elif c in "\"'":
|
||||
quote = c
|
||||
elif c == ">":
|
||||
return panel[i:j + 1]
|
||||
raise AssertionError(marker)
|
||||
|
||||
|
||||
def test_panel_does_not_scroll():
|
||||
tag = _tag_with(_panel(), 'x-data="visionExecute()"')
|
||||
assert "overflow-hidden" in tag and "min-h-0" in tag
|
||||
assert "overflow-auto" not in tag
|
||||
|
||||
|
||||
def test_image_is_object_contain_without_h_auto():
|
||||
panel = _panel()
|
||||
img = _tag_with(panel, 'x-ref="img"')
|
||||
assert "object-contain" in img
|
||||
assert "h-auto" not in img
|
||||
canvas = _tag_with(panel, 'x-ref="overlay"')
|
||||
assert "object-contain" in canvas # stesso fit dell'img: overlay allineato
|
||||
|
||||
|
||||
def test_image_box_and_outcome_column_can_shrink():
|
||||
panel = _panel()
|
||||
box = _tag_with(panel, 'x-show="shownImage"')
|
||||
assert "flex-1" in box and "min-h-0" in box
|
||||
outcome = _tag_with(panel, 'x-show="result">')
|
||||
assert "overflow-y-auto" in outcome and "min-h-0" in outcome
|
||||
Reference in New Issue
Block a user