Compare commits

...

10 Commits

Author SHA1 Message Date
Adriano 67337fa232 operatore: immagine gia' analizzata all'ingresso, analisi impulsiva o in linea, Registra (Refs #97)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 18:30:31 +02:00
Adriano b98d1f15f4 operatore: immagine del task visione in fit, pagina senza scorrimento (Refs #96)
Pannello visione overflow-hidden/min-h-0, immagine e overlay object-contain
sovrapposti nel riquadro flex-1, colonna esito con scorrimento proprio.
Test sulle classi del template.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 18:20:15 +02:00
Adriano 552f96bb47 acquisitore: calibrazione manuale con mm/px impostato a mano (Refs #95)
POST /camera/calibrazioni/manuale salva una voce senza mappa (metodo manuale);
anteprima/calibrata senza mappa risponde il frame grezzo (X-Raddrizzato: no);
scheda «Calibrazione manuale» nella tab Calibrazione.

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

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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 16:41:18 +02:00
Adriano 4a48af2ae3 merge #92 pagina: sorgente in cima, grezza e calibrata affiancate 2026-10-24 16:28:00 +02:00
Adriano b879563738 merge #92 servizio: immagini come sorgenti, anteprima calibrata 2026-10-24 16:28:00 +02:00
Adriano f504c86491 Acquisizione: immagine da file come sorgente, riquadri grezza e calibrata affiancati (Refs #92)
Pagina: Sorgente in cima alla tab Acquisizione con carica/rimuovi immagine,
riquadro Calibrata con casella, calibrazione sempre dal frame grezzo della
sorgente, ciclo anteprima sospeso nella tab Calibrazione.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 16:27:20 +02:00
13 changed files with 1572 additions and 220 deletions
+539 -161
View File
@@ -12,6 +12,11 @@ Parla con le rotte dello stesso servizio: /camera/sources, /camera/anteprima,
visibile in entrambe. Calibrazioni salvate con nome in un archivio unico visibile in entrambe. Calibrazioni salvate con nome in un archivio unico
(/camera/calibrazioni, /camera/calibrazione-attiva); il calcolo non salva piu': (/camera/calibrazioni, /camera/calibrazione-attiva); il calcolo non salva piu':
si salva dal calcolo_id con un nome. 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. 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; } 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=range] { width: 100%; accent-color: var(--accento); }
input[type=checkbox] { 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; } .riga { display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-end; }
.nota { font-size: .82rem; color: var(--secondario); margin: 8px 0 0; } .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; 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; } 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 { 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; } .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, background: linear-gradient(#fbbf24, #fbbf24) center / 2px 100% no-repeat,
linear-gradient(#fbbf24, #fbbf24) center / 100% 2px no-repeat; opacity: .85; } 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; } .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 { background: var(--sfondo); border: 1px solid var(--bordo); border-radius: 6px; padding: 8px 10px; }
.indicatore .nome { font-size: .78rem; color: var(--secondario); } .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; } .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 { 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; } .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) { @media (max-width: 900px) {
.griglia { grid-template-columns: minmax(0, 1fr); } .griglia { grid-template-columns: minmax(0, 1fr); }
.riquadri.doppio { grid-template-columns: minmax(0, 1fr); } /* #92: in colonna */
.cornice img { max-height: 60vh; } .cornice img { max-height: 60vh; }
} }
@media (max-width: 500px) { /* #91: utilizzabile anche stretta (400 px) */ @media (max-width: 500px) { /* #91: utilizzabile anche stretta (400 px) */
@@ -112,125 +127,169 @@ PAGINA = r"""<!doctype html>
<main> <main>
<h1>Acquisizione camera</h1> <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"> <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="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> <button id="tabCal" type="button" role="tab" aria-selected="false" aria-controls="pannelloCal" tabindex="-1">Calibrazione</button>
</div> </div>
<div class="griglia"> <div class="colonna" id="pannelloAcq" role="tabpanel" aria-labelledby="tabAcq">
<div class="colonna"> <!-- #92: Sorgente in cima, a larghezza piena: camere, cartella e immagini caricate da file -->
<!-- #87: sezione Anteprima --> <section class="scheda" id="sezioneSorgente">
<section class="scheda" id="sezioneAnteprima"> <h2>Sorgente</h2>
<h2>Anteprima</h2> <div class="sorgente-riga">
<div class="riga"> <div class="campo-sorgente">
<button id="avvia">Avvia anteprima</button> <label for="sorgente">Camera o immagine</label>
<button id="ferma" class="secondario" disabled>Ferma anteprima</button> <select id="sorgente"></select>
<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> </div>
<div class="palco"> <button id="aggiornaSorgenti" class="secondario" type="button" title="Cerca di nuovo le camere collegate">Aggiorna</button>
<div id="vuoto">Anteprima ferma. Premere «Avvia anteprima» o «Scatta».</div> <button id="caricaImmagine" class="secondario" type="button">Carica immagine da file...</button>
<div class="cornice" id="cornice" hidden> <input id="fileImmagine" type="file" accept="image/*" hidden>
<img id="immagine" alt="Anteprima della camera"> <button id="rimuoviImmagine" class="secondario" type="button" hidden>Rimuovi immagine</button>
<div id="mirino" hidden></div> </div>
</div> <div class="stato" id="statoSorgente">-</div>
</div> <div id="msgSorgente" class="messaggio"></div>
<div class="indicatori"> </section>
<div class="indicatore">
<div class="nome">Nitidezza</div>
<div class="valore" id="nitidezza">-</div>
<div class="barra"><div id="barraNitidezza"></div></div>
<div class="piccolo">massimo visto: <span id="nitidezzaMax">-</span>
<button id="azzeraMax" class="secondario" style="padding:1px 7px;font-size:.75rem;margin-left:6px">Azzera</button></div>
</div>
<div class="indicatore">
<div class="nome">Saturati</div>
<div class="valore" id="saturati">-</div>
<div class="piccolo">pixel a 250 o più; da tenere sotto l'1 %</div>
</div>
<div class="indicatore">
<div class="nome">Livello medio</div>
<div class="valore" id="media">-</div>
<div class="piccolo">scala 0-255</div>
</div>
<div class="indicatore">
<div class="nome">Frame pieno</div>
<div class="valore" id="dimensioni">-</div>
<div class="piccolo" id="raddrizzato">&nbsp;</div>
</div>
</div>
<div id="msgAnteprima" class="messaggio"></div>
</section>
</div>
<div class="colonna" id="pannelloAcq" role="tabpanel" aria-labelledby="tabAcq"> <!-- #87: sezione Anteprima; #92: barra, due riquadri affiancati (grezza | calibrata), indicatori dal frame grezzo -->
<!-- #87: sezione Sorgente --> <section class="scheda" id="sezioneAnteprima">
<section class="scheda" id="sezioneSorgente"> <h2>Anteprima</h2>
<h2>Sorgente</h2> <div class="riga">
<label for="sorgente">Camera o cartella</label> <button id="avvia">Avvia anteprima</button>
<select id="sorgente"></select> <button id="ferma" class="secondario" disabled>Ferma anteprima</button>
<div class="stato" id="statoSorgente">-</div> <button id="scatta" class="secondario">Scatta</button>
<div id="msgSorgente" class="messaggio"></div> <label style="margin:0 0 6px"><input id="mostraCal" type="checkbox"> Attiva calibrazione</label>
</section> <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">&nbsp;</div>
</div>
</div>
<div id="msgAnteprima" class="messaggio"></div>
</section>
<!-- #87: sezione Parametri camera --> <!-- #87: sezione Parametri camera -->
<section class="scheda" id="sezioneParametri"> <section class="scheda" id="sezioneParametri">
<h2>Parametri camera</h2> <h2>Parametri camera</h2>
<div id="parametri"><div class="nota">-</div></div> <div id="parametri"><div class="nota">-</div></div>
<div class="riga" id="salvaParametriRiga" hidden> <div class="riga" id="salvaParametriRiga" hidden>
<button id="salvaParametri" class="secondario">Salva come predefiniti</button> <button id="salvaParametri" class="secondario">Salva come predefiniti</button>
</div> </div>
<div id="msgParametri" class="messaggio"></div> <div id="msgParametri" class="messaggio"></div>
</section> </section>
</div> </div>
<!-- #91: tab Calibrazione (colonna destra al posto di Sorgente e Parametri) --> <!-- #91: tab Calibrazione; #92: sempre sul frame grezzo della sorgente scelta in Acquisizione, niente file qui -->
<div class="colonna" id="pannelloCal" role="tabpanel" aria-labelledby="tabCal" hidden> <div class="griglia" id="pannelloCal" role="tabpanel" aria-labelledby="tabCal" hidden>
<section class="scheda" id="sezioneCalibrazione"> <section class="scheda" id="sezioneCalibrazione">
<h2>Calibrazione</h2> <h2>Calibrazione</h2>
<div class="stato" style="margin:0 0 10px">Sorgente: <strong id="calSorgente">-</strong> (si cambia nella tab Acquisizione)</div> <div class="stato" style="margin:0 0 10px">Sorgente: <strong id="calSorgente">-</strong> — immagine grezza dalla tab Acquisizione</div>
<div class="origine"> <!-- #94: immagine viva (stesso frame dell'anteprima) e «Blocca immagine» per calcolare su quella -->
<label for="origSorgente"><input id="origSorgente" name="origine" type="radio" value="sorgente" checked> Sorgente selezionata</label> <div class="palco" style="margin-top:0">
<label for="origFile"><input id="origFile" name="origine" type="radio" value="file"> Immagine da file</label> <div id="vuotoCalTab">Nessuna immagine: «Scatta» o avviare l'anteprima nella tab Acquisizione</div>
</div> <div class="cornice" id="corniceCalTab" hidden>
<div class="riga" style="margin-top:4px"> <img id="immagineCalTab" alt="Immagine grezza della sorgente">
<button id="scegliFile" class="secondario">Scegli immagine...</button>
<input id="file" type="file" accept="image/*" hidden>
<span id="nomeFile" class="nota" style="margin:0 0 6px">nessuna immagine scelta</span>
</div> </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 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> <div><label for="nomeCal">Nome</label><input id="nomeCal" type="text" maxlength="60" size="22" autocomplete="off"></div>
<button id="acquisisci" title="Acquisisci e calibra">Calcola</button> <button id="salva">Salva</button>
</div> </div>
<p class="nota"> <div id="msgSalva" class="messaggio"></div>
Servono una scacchiera che riempie tutto il campo della camera, piana, ferma e ben illuminata, e il lato di uno scacco. <img id="overlay" alt="Angoli trovati sulla scacchiera" style="max-width:100%;margin-top:8px;border:1px solid var(--bordo);border-radius:6px">
La griglia si trova da sola; il risultato corregge anche la distorsione dell'ottica. </div>
Calcolando dalla sorgente l'anteprima si sospende e poi riprende.</p> </section>
<div id="messaggio" class="messaggio"></div>
<div id="risultato" hidden style="margin-top:10px"> <!-- #95: calibrazione manuale, mm per pixel impostato a mano (nessuna scacchiera) -->
<strong>Risultato del calcolo</strong> <section class="scheda" id="sezioneManuale">
<div id="origineCalcolo" class="nota" style="margin:2px 0 6px"></div> <h2>Calibrazione manuale</h2>
<table id="tabella"></table> <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 id="avviso" class="avviso"></div> <div class="riga" style="margin-top:8px">
<div class="riga" style="margin-top:10px"> <div><label for="mmPx">mm per pixel</label><input id="mmPx" type="text" inputmode="decimal" size="10" autocomplete="off"></div>
<div><label for="nomeCal">Nome</label><input id="nomeCal" type="text" maxlength="60" size="22" autocomplete="off"></div> <div class="nota" id="pxMmCalc" style="margin:0 0 8px">= - px/mm</div>
<button id="salva">Salva</button> </div>
</div> <div class="riga" style="margin-top:6px">
<div id="msgSalva" class="messaggio"></div> <div><label for="nomeManuale">Nome</label><input id="nomeManuale" type="text" maxlength="60" size="22" autocomplete="off"></div>
<img id="overlay" alt="Angoli trovati sulla scacchiera" style="max-width:100%;margin-top:8px;border:1px solid var(--bordo);border-radius:6px"> <button id="salvaManuale">Salva</button>
</div> </div>
</section> <div id="notaManuale" class="nota"></div>
<div id="msgManuale" class="messaggio"></div>
</section>
<section class="scheda" id="sezioneSalvate"> <section class="scheda" id="sezioneSalvate">
<h2>Calibrazioni salvate</h2> <h2>Calibrazioni salvate</h2>
<div id="stato" class="stato">-</div> <div id="stato" class="stato">-</div>
<div id="calibrazioni" class="elenco"></div> <!-- #91: una scheda per calibrazione, niente tabella larga --> <div id="calibrazioni" class="elenco"></div> <!-- #91: una scheda per calibrazione, niente tabella larga -->
<div id="msgElenco" class="messaggio"></div> <div id="msgElenco" class="messaggio"></div>
</section> </section>
</div>
</div> </div>
</main> </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 // numero all'italiana (virgola decimale); cifre = decimali fissi, omesso = fino a 4
function fmt(v, cifre) { 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); var n = Number(v);
if (!isFinite(n)) return "-"; if (!isFinite(n)) return "-";
var o = cifre === undefined ? { maximumFractionDigits: 4 } : { minimumFractionDigits: cifre, maximumFractionDigits: cifre }; var o = cifre === undefined ? { maximumFractionDigits: 4 } : { minimumFractionDigits: cifre, maximumFractionDigits: cifre };
@@ -289,32 +349,123 @@ async function dettaglio(risposta) {
// ---- Sorgente // ---- 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 sel = $("sorgente");
var prima = sel.value;
sel.textContent = "";
try { try {
var r = await fetch("/camera/sources"); var r = await fetch("/camera/sources" + (aggiorna ? "?aggiorna=true" : ""));
var j = await r.json(); var j = await r.json();
(j.sources || []).forEach(function (s) { (j.sources || []).forEach(function (s) {
var o = document.createElement("option"); var o = document.createElement("option");
o.value = s.name; o.value = s.name;
o.textContent = s.label || s.name; o.textContent = s.label || s.name;
o.dataset.kind = s.kind || ""; // #92: «image» = immagine caricata da file
sel.appendChild(o); sel.appendChild(o);
}); });
} catch (e) { dire("msgSorgente", "Elenco delle sorgenti non disponibile.", "errore"); } } catch (e) { dire("msgSorgente", "Elenco delle sorgenti non disponibile.", "errore"); }
if (!sel.options.length) dire("msgSorgente", "Nessuna sorgente camera trovata.", "errore"); if (!sel.options.length) dire("msgSorgente", "Nessuna sorgente camera trovata.", "errore");
var ricordata = memoria("tmf.acq.sorgente"); // #87: ultima scelta ricordata var voluta = scegli || (prima === "" ? memoria("tmf.acq.sorgente") : prima); // #87: ultima scelta ricordata
if (ricordata) { if (voluta) {
for (var i = 0; i < sel.options.length; i++) if (sel.options[i].value === ricordata) sel.value = ricordata; 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 // #87: cambiando sorgente si azzerano gli indicatori e si rileggono parametri e calibrazione
function cambioSorgente(salva) { function cambioSorgente(salva) {
if (salva) memoria("tmf.acq.sorgente", $("sorgente").value); if (salva) memoria("tmf.acq.sorgente", $("sorgente").value);
var era = A.attiva; var era = A.attiva;
fermaAnteprima(); fermaAnteprima();
if (A.bloccata) rilasciaBlocco(); // #94: casella spenta e DELETE sulla sorgente vecchia (prima di svuotare i riquadri)
azzeraIndicatori(); 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("msgAnteprima", "");
dire("msgElenco", ""); // #91 dire("msgElenco", ""); // #91
aggiornaStato(); aggiornaStato();
@@ -324,7 +475,8 @@ function cambioSorgente(salva) {
// ---- Anteprima (#87) // ---- 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() { function azzeraIndicatori() {
A.nitMax = 0; A.nitMax = 0;
@@ -345,9 +497,74 @@ function mostraImmagine(blob) {
var vecchio = A.url; var vecchio = A.url;
A.url = URL.createObjectURL(blob); A.url = URL.createObjectURL(blob);
$("immagine").src = A.url; $("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; $("cornice").hidden = false;
$("vuoto").hidden = true; $("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) { function aggiornaIndicatori(h) {
@@ -372,8 +589,7 @@ function aggiornaIndicatori(h) {
var nw = parseInt(w, 10), na = parseInt(a, 10); 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(); } 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 = "frame grezzo"; // #92: l'indicatore viene sempre dal frame grezzo
$("raddrizzato").textContent = r === "si" ? "raddrizzata dalla calibrazione" : r === "no" ? "non raddrizzata" : " ";
} }
// una richiesta di anteprima; ritorna "ok" | "errore" | "fatale" (errore che non passa riprovando) // 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; var sorgente = $("sorgente").value;
if (!sorgente) { dire("msgAnteprima", "Scegliere una sorgente.", "errore"); return "fatale"; } if (!sorgente) { dire("msgAnteprima", "Scegliere una sorgente.", "errore"); return "fatale"; }
var g = A.gen; var g = A.gen;
A.lato = lato;
A.inVolo = true; A.inVolo = true;
try { try {
var r = await fetch("/camera/anteprima", { var r = await fetch("/camera/anteprima", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, 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) { if (!r.ok) {
var d = await dettaglio(r); var d = await dettaglio(r);
@@ -398,6 +615,7 @@ async function unaAnteprima(lato) {
mostraImmagine(blob); mostraImmagine(blob);
aggiornaIndicatori(r.headers); aggiornaIndicatori(r.headers);
dire("msgAnteprima", ""); dire("msgAnteprima", "");
await aggiornaCalibrata(lato); // #92: stesso frame, raddrizzato; resta in volo finche' non risponde
return "ok"; return "ok";
} catch (e) { } catch (e) {
if (g === A.gen) dire("msgAnteprima", "Servizio non raggiungibile.", "errore"); if (g === A.gen) dire("msgAnteprima", "Servizio non raggiungibile.", "errore");
@@ -411,6 +629,8 @@ async function unaAnteprima(lato) {
async function ciclo(g) { async function ciclo(g) {
while (A.inVolo) await pausa(50); while (A.inVolo) await pausa(50);
while (A.attiva && g === A.gen) { 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 t0 = Date.now();
var esito = await unaAnteprima(1280); var esito = await unaAnteprima(1280);
if (g !== A.gen) return; 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) // «Scatta»: ferma il ciclo e mostra un frame solo, alla risoluzione piena (fino a 4096 px)
async function scatta() { async function scatta() {
fermaAnteprima(); fermaAnteprima();
$("scatta").disabled = true; $("scatta").disabled = $("scattaCal").disabled = true; // #94: anche il «Scatta» della tab Calibrazione
dire("msgAnteprima", "Scatto in corso...", ""); dire("msgAnteprima", "Scatto in corso...", "");
while (A.inVolo) await pausa(50); while (A.inVolo) await pausa(50);
while (A.bloccando) await pausa(20); // #94: niente scatto mentre si blocca l'immagine
await unaAnteprima(4096); await unaAnteprima(4096);
$("scatta").disabled = false; $("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; $("avvia").onclick = avviaAnteprima;
$("ferma").onclick = fermaAnteprima; $("ferma").onclick = fermaAnteprima;
$("scatta").onclick = scatta; $("scatta").onclick = scatta;
$("scattaCal").onclick = scatta; // #94
$("azzeraMax").onclick = function () { A.nitMax = 0; $("nitidezzaMax").textContent = "-"; $("barraNitidezza").style.width = "0"; }; $("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) // ---- Parametri camera (#87)
@@ -676,6 +961,8 @@ function scegliTab(nome, salva) {
$("tabCal").tabIndex = cal ? 0 : -1; $("tabCal").tabIndex = cal ? 0 : -1;
$("pannelloAcq").hidden = cal; $("pannelloAcq").hidden = cal;
$("pannelloCal").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 if (salva) { memoria("tmf.acq.tab", nome); if (cal) caricaCalibrazioni(); } // l'elenco si rilegge entrando
} }
$("tabAcq").onclick = function () { scegliTab("acq", true); }; $("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) // ---- Calibrazione (logica di #83 e #84; #87: lato precompilato, data italiana, conferma in pagina)
function righe(d) { 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 [ return [
["Scala", fmt(d.px_per_mm, 4) + " px/mm (" + fmt(d.mm_per_px, 5) + " mm/px)"], ["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)"], ["Metodo", d.metodo !== "densa" ? "planare (senza correzione della distorsione)" : "densa (immagine raddrizzata)"],
@@ -748,6 +1040,7 @@ async function aggiornaStato() {
var stato = $("stato"); var stato = $("stato");
annullaConferma(); annullaConferma();
$("calSorgente").textContent = sorgente ? $("sorgente").options[$("sorgente").selectedIndex].textContent : "-"; // #91 $("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; } if (!sorgente) { stato.textContent = "-"; $("statoSorgente").textContent = "-"; caricaCalibrazioni(); return; }
try { try {
var r = await fetch("/camera/calibration?source=" + encodeURIComponent(sorgente)); var r = await fetch("/camera/calibration?source=" + encodeURIComponent(sorgente));
@@ -755,10 +1048,12 @@ async function aggiornaStato() {
if (r.status === 404) { if (r.status === 404) {
stato.textContent = "Sorgente non calibrata."; stato.textContent = "Sorgente non calibrata.";
$("statoSorgente").textContent = "non calibrata"; $("statoSorgente").textContent = "non calibrata";
A.calAttiva = false; // #92
} else if (!r.ok) { } else if (!r.ok) {
stato.textContent = await dettaglio(r); stato.textContent = await dettaglio(r);
} else { } else {
var d = await r.json(); var d = await r.json();
A.calAttiva = true; // #92
stato.textContent = ""; stato.textContent = "";
var titolo = document.createElement("div"); var titolo = document.createElement("div");
titolo.textContent = "Calibrazione attiva: " + (d.nome ? "«" + d.nome + "»" : "(senza nome)"); titolo.textContent = "Calibrazione attiva: " + (d.nome ? "«" + d.nome + "»" : "(senza nome)");
@@ -770,8 +1065,8 @@ async function aggiornaStato() {
stato.appendChild(titolo); stato.appendChild(titolo);
stato.appendChild(t); stato.appendChild(t);
stato.appendChild(quando); stato.appendChild(quando);
$("statoSorgente").textContent = "calibrata: " + fmt(d.px_per_mm, 2) + " px/mm"; $("statoSorgente").textContent = "calibrata: " + (d.nome || "(senza nome)") + " · " + fmt(d.px_per_mm, 2) + " px/mm"; // #92
if (d.square_mm) $("quadrato").value = fmtCasella(d.square_mm); // #87: parte dal lato gia' usato if (Number(d.square_mm) > 0) $("quadrato").value = fmtCasella(d.square_mm); // #87: parte dal lato gia' usato
} }
} catch (e) { } catch (e) {
stato.textContent = "Servizio non raggiungibile."; stato.textContent = "Servizio non raggiungibile.";
@@ -810,6 +1105,7 @@ function disegnaElenco() {
annullaConferma(); annullaConferma();
t.textContent = ""; t.textContent = "";
C.celle = []; C.celle = [];
disegnaSceltaCal(); // #93: la combo sotto la calibrata segue l'elenco, anche se vuoto
if (!C.elenco.length) { if (!C.elenco.length) {
var vuota = document.createElement("div"); var vuota = document.createElement("div");
vuota.className = "nota"; vuota.className = "nota";
@@ -841,7 +1137,7 @@ function disegnaElenco() {
riga2.textContent = [ riga2.textContent = [
c.image_width && c.image_height ? fmt(c.image_width, 0) + "×" + fmt(c.image_height, 0) : "-", c.image_width && c.image_height ? fmt(c.image_width, 0) + "×" + fmt(c.image_height, 0) : "-",
fmt(c.px_per_mm, 3) + " px/mm", 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) : "-", c.calibrated_at ? dataItaliana(c.calibrated_at) : "-",
descriviOrigine(c.origine) descriviOrigine(c.origine)
].join(" · "); ].join(" · ");
@@ -850,7 +1146,7 @@ function disegnaElenco() {
var bc = bottone("Carica", "", function () { caricaNome(c.nome); }); var bc = bottone("Carica", "", function () { caricaNome(c.nome); });
bc.disabled = !$("sorgente").value; bc.disabled = !$("sorgente").value;
az.appendChild(bc); 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); })); az.appendChild(bottone("Elimina", "secondario", function (e) { eliminaNome(c.nome, e.currentTarget); }));
sc.appendChild(riga1); sc.appendChild(riga1);
sc.appendChild(riga2); sc.appendChild(riga2);
@@ -865,39 +1161,80 @@ function segnaRisoluzioni() {
(C.celle || []).forEach(function (c) { (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)); 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. // «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; var sorgente = $("sorgente").value;
if (!sorgente) { dire("msgElenco", "Scegliere una sorgente nella tab Acquisizione.", "errore"); return; } if (!sorgente) { dire(idMsg, "Scegliere una sorgente nella tab Acquisizione.", "errore"); return; }
dire("msgElenco", "Caricamento di «" + nome + "»...", ""); dire(idMsg, "Caricamento di «" + nome + "»...", "");
try { try {
var r = await fetch("/camera/calibrazione-attiva", { method: "PUT", headers: JSON_H, body: JSON.stringify({ source: sorgente, nome: nome }) }); 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 (sorgente !== $("sorgente").value) return;
if (!r.ok) { dire("msgElenco", await dettaglio(r), "errore"); return; } if (!r.ok) { dire(idMsg, await dettaglio(r), "errore"); disegnaSceltaCal(); return; } // #93: la combo torna sull'attiva
dire("msgElenco", "Calibrazione «" + nome + "» caricata sulla sorgente.", "ok"); dire(idMsg, "Calibrazione «" + nome + "» caricata sulla sorgente.", "ok");
$("raddrizzato").textContent = $("grezzo").checked ? "non raddrizzata" : "raddrizzata dalla calibrazione"; // il prossimo frame conferma A.bloccoCal = false; // #92
aggiornaStato(); await aggiornaStato(); // prima lo stato nuovo (rilegge anche l'elenco, #93: schede e combo allineate), poi il riquadro calibrata
aggiornaCalibrata();
} catch (e) { } 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 // «Scollega»: la sorgente torna senza calibrazione, la libreria resta
async function scollega() { async function scollega(idMsg) {
idMsg = idMsg || "msgElenco"; // #93
var sorgente = $("sorgente").value; var sorgente = $("sorgente").value;
if (!sorgente) return; if (!sorgente) return;
try { try {
var r = await fetch("/camera/calibration?source=" + encodeURIComponent(sorgente), { method: "DELETE" }); var r = await fetch("/camera/calibration?source=" + encodeURIComponent(sorgente), { method: "DELETE" });
dire("msgElenco", r.ok ? "Calibrazione scollegata dalla sorgente (resta fra le salvate)." : await dettaglio(r), r.ok ? "ok" : "errore"); dire(idMsg, r.ok ? "Calibrazione scollegata dalla sorgente (resta fra le salvate)." : await dettaglio(r), r.ok ? "ok" : "errore");
if (r.ok) $("raddrizzato").textContent = "non raddrizzata";
} catch (e) { } 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) // «Elimina» di una riga: conferma in pagina con un secondo clic entro 4 s (niente finestre del browser)
function annullaConferma() { function annullaConferma() {
clearTimeout(C.confermaTimer); 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» // #91: il calcolo non salva mai; il servizio tiene il risultato e ne da' il calcolo_id per «Salva»
async function calcola() { async function calcola() {
var daFile = $("origFile").checked; var sorgente = $("sorgente").value; // #92: sempre la sorgente della tab Acquisizione, frame grezzo
var sorgente = $("sorgente").value; if (!sorgente) { messaggio("Scegliere una sorgente nella tab Acquisizione.", "errore"); return; }
if (!daFile && !sorgente) { messaggio("Scegliere una sorgente nella tab Acquisizione.", "errore"); return; }
var lato = leggiNumero($("quadrato").value); var lato = leggiNumero($("quadrato").value);
if (!(lato > 0)) { messaggio("Il lato dello scacco deve essere un numero maggiore di zero.", "errore"); return; } if (!(lato > 0)) { messaggio("Il lato dello scacco deve essere un numero maggiore di zero.", "errore"); return; }
var f = new FormData(); var f = new FormData();
f.append("square_mm", String(lato)); // al servizio sempre con il punto f.append("square_mm", String(lato)); // al servizio sempre con il punto
if (daFile) { f.append("source", sorgente);
var file = $("file").files[0]; var bloccata = A.bloccata; // #94: immagine bloccata = calcolo sulla copia del servizio, l'anteprima puo' continuare
if (!file) { messaggio("Scegliere un file immagine.", "errore"); return; } if (bloccata) f.append("bloccata", "true");
f.append("image", file); else fermaAnteprima(); // #92: si ferma il ciclo; sotto si aspetta anche l'eventuale richiesta ancora in volo
} else {
f.append("source", sorgente);
}
var eraAttiva = !daFile && A.attiva; // #87: da sorgente l'anteprima si sospende e poi riprende
if (eraAttiva) fermaAnteprima();
messaggio("Calcolo in corso...", ""); messaggio("Calcolo in corso...", "");
["acquisisci", "salva"].forEach(function (i) { $(i).disabled = true; }); ["acquisisci", "salva"].forEach(function (i) { $(i).disabled = true; });
try { try {
while (!bloccata && A.inVolo) await pausa(50);
var r = await fetch("/camera/calibrate", { method: "POST", body: f }); var r = await fetch("/camera/calibrate", { method: "POST", body: f });
if (!r.ok) { messaggio(await dettaglio(r), "errore"); return; } if (!r.ok) { messaggio(await dettaglio(r), "errore"); return; }
var d = await r.json(); var d = await r.json();
@@ -953,7 +1285,7 @@ async function calcola() {
tabella($("tabella"), d); tabella($("tabella"), d);
$("overlay").src = "data:image/png;base64," + d.overlay_png; $("overlay").src = "data:image/png;base64," + d.overlay_png;
$("avviso").textContent = avvisi(d); $("avviso").textContent = avvisi(d);
$("origineCalcolo").textContent = "Origine: " + descriviOrigine(d.origine); $("origineCalcolo").textContent = "Origine: " + (d.bloccata ? "immagine bloccata — " : "") + descriviOrigine(d.origine); // #94
$("risultato").hidden = false; $("risultato").hidden = false;
annullaSovrascrivi(); annullaSovrascrivi();
dire("msgSalva", ""); dire("msgSalva", "");
@@ -963,7 +1295,6 @@ async function calcola() {
} finally { } finally {
$("acquisisci").disabled = false; $("acquisisci").disabled = false;
$("salva").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; $("acquisisci").onclick = calcola;
$("salva").onclick = salvaNome; $("salva").onclick = salvaNome;
$("nomeCal").onkeydown = function (e) { if (e.key === "Enter") salvaNome(); }; $("nomeCal").onkeydown = function (e) { if (e.key === "Enter") salvaNome(); };
// #87: il pulsante del file e' in italiano, l'input nativo resta nascosto $("sorgente").onchange = function () { dire("msgSorgente", ""); cambioSorgente(true); }; // #92
$("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); };
scegliTab(memoria("tmf.acq.tab") === "cal" ? "cal" : "acq", false); // #91: tab ricordata scegliTab(memoria("tmf.acq.tab") === "cal" ? "cal" : "acq", false); // #91: tab ricordata
caricaSorgenti(); caricaSorgenti();
</script> </script>
+111 -16
View File
@@ -29,6 +29,10 @@ Calibrazione a scacchiera (#83, logica in scripts/calibrazione.py).
calibrated_at, attiva_su}], attiva: nome|null} (dalla piu' recente) calibrated_at, attiva_su}], attiva: nome|null} (dalla piu' recente)
POST /camera/calibrazioni json {calcolo_id, nome, sovrascrivi=false} -> 201 POST /camera/calibrazioni json {calcolo_id, nome, sovrascrivi=false} -> 201
metadati. 404 calcolo scaduto, 400 nome non valido, 409 nome gia' usato 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 DELETE /camera/calibrazioni?nome=X -> 204 (toglie anche la mappa e la scollega
da ogni sorgente) | 404 da ogni sorgente) | 404
PUT /camera/calibrazione-attiva json {source, nome}: legge un frame dalla PUT /camera/calibrazione-attiva json {source, nome}: legge un frame dalla
@@ -84,10 +88,12 @@ from __future__ import annotations
import argparse import argparse
import logging import logging
import math # #95: mm/px finito nella calibrazione manuale
import os import os
import re # #92: nome sicuro per le immagini caricate import re # #92: nome sicuro per le immagini caricate
import sys import sys
import threading import threading
import time # #92: scadenza della cache delle camere
import urllib.parse # #92: nome calibrazione nell'header X-Calibrazione import urllib.parse # #92: nome calibrazione nell'header X-Calibrazione
import uuid # #91 id dei calcoli in attesa di nome import uuid # #91 id dei calcoli in attesa di nome
from collections import OrderedDict from collections import OrderedDict
@@ -110,6 +116,7 @@ from calibrazione_pagina import PAGINA # noqa: E402
_TRIGGER = ("continuo",) _TRIGGER = ("continuo",)
_MAX_CALCOLI = 8 # #91: calcoli tenuti in memoria in attesa di un nome _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) _log = logging.getLogger("camera_service") # #87: errori non bloccanti (settaggi)
# #87: i quattro parametri regolabili, nell'ordine di presentazione # #87: i quattro parametri regolabili, nell'ordine di presentazione
_PARAMETRI = ("esposizione", "esposizione_auto", "guadagno", "guadagno_auto") _PARAMETRI = ("esposizione", "esposizione_auto", "guadagno", "guadagno_auto")
@@ -154,6 +161,7 @@ def create_app(folder: Path | str | None = None,
else _config_dir_default()) # #83 else _config_dir_default()) # #83
aperte: dict = {} aperte: dict = {}
grezzi: dict = {} # #92: sorgente -> ultimo frame pieno GREZZO dell'anteprima (per anteprima/calibrata) 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) cartella_immagini = archivio.cartella / "immagini" # #92: immagini caricate dalla pagina (creata al bisogno)
lock = threading.Lock() # protegge SOLO i dizionari, mai un'apertura lock = threading.Lock() # protegge SOLO i dizionari, mai un'apertura
lock_sorgenti: dict = {} # nome sorgente -> Lock che serializza apertura e grab 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: with lock:
return lock_sorgenti.setdefault(nome, threading.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 @asynccontextmanager
async def _vita(_app): 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 yield
with lock: # alla chiusura rilascia le camere aperte with lock: # alla chiusura rilascia le camere aperte
sorgenti = list(aperte.values()) 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 = FastAPI(title="TMF camera service", lifespan=_vita)
@app.get("/camera/sources") @app.get("/camera/sources")
def camera_sources(): def camera_sources(aggiorna: bool = False): # #92: aggiorna=true forza la ricerca (camera appena collegata)
voci = list_sources(None, cameras=True, config=_config()) elenco = list(_camere(aggiorna))
# #92: camera:file (replay) non e' mai configurato in TMF -> fuori dall'elenco # #92: in coda le immagini caricate, sempre fresche dalla cartella (costano nulla)
elenco = [{"name": v.name, "kind": v.kind, "label": v.label}
for v in voci if v.kind in ("camera", "virtual") and v.name != "camera:file"]
# #92: in coda le immagini caricate, che sono sorgenti a pieno titolo
for v in list_sources(cartella_immagini, cameras=False): for v in list_sources(cartella_immagini, cameras=False):
elenco.append({"name": v.name, "kind": "image", "label": f"immagine — {v.name}"}) elenco.append({"name": v.name, "kind": "image", "label": f"immagine — {v.name}"})
return {"sources": elenco} return {"sources": elenco}
@@ -200,6 +229,7 @@ def create_app(folder: Path | str | None = None,
with lock: with lock:
s = aperte.pop(nome, None) s = aperte.pop(nome, None)
grezzi.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: if s is not None:
try: try:
s.close() s.close()
@@ -345,6 +375,7 @@ def create_app(folder: Path | str | None = None,
rows: int | None = Form(None), rows: int | None = Form(None),
image: UploadFile | None = File(None), image: UploadFile | None = File(None),
save: bool = Form(False), # #91: ignorato, il calcolo non salva mai (si salva con nome) 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"" contenuto = await image.read() if image is not None else b""
if contenuto: if contenuto:
@@ -352,6 +383,13 @@ def create_app(folder: Path | str | None = None,
if frame is None: if frame is None:
raise HTTPException(422, "l'immagine caricata non è leggibile") raise HTTPException(422, "l'immagine caricata non è leggibile")
origine = "file:" + (Path((image.filename or "").replace("\\", "/")).name or "immagine") 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: else:
nome = _sorgente(source) nome = _sorgente(source)
frame = await run_in_threadpool(_grab, nome, 5000) # come /camera/grab (404/503/504) 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) calcoli[calcolo_id] = (corpo, mappa)
while len(calcoli) > _MAX_CALCOLI: while len(calcoli) > _MAX_CALCOLI:
calcoli.popitem(last=False) 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 # ---- #91: archivio unico di calibrazioni con nome
@@ -406,6 +445,35 @@ def create_app(folder: Path | str | None = None,
except calibrazione.NomeGiaUsato as e: except calibrazione.NomeGiaUsato as e:
raise HTTPException(409, str(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) @app.delete("/camera/calibrazioni", status_code=204)
def calibrazioni_elimina(nome: str = ""): def calibrazioni_elimina(nome: str = ""):
if not archivio.elimina_nome(nome): 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-Nitidezza": f"{nitidezza:.1f}", "X-Saturati-Pct": f"{saturati:.2f}",
"X-Media": f"{media:.1f}"}) "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") @app.post("/camera/anteprima/calibrata")
def camera_anteprima_calibrata(corpo: dict = Body(default=None)): 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 {} corpo = corpo if isinstance(corpo, dict) else {}
nome = _sorgente(corpo.get("source")) nome = _sorgente(corpo.get("source"))
lato = corpo.get("lato_max", 1280) lato = corpo.get("lato_max", 1280)
@@ -524,14 +611,22 @@ def create_app(folder: Path | str | None = None,
if attiva is None: if attiva is None:
raise HTTPException(409, "nessuna calibrazione attiva su questa sorgente") raise HTTPException(409, "nessuna calibrazione attiva su questa sorgente")
mappa = archivio.leggi_mappa(nome) mappa = archivio.leggi_mappa(nome)
if mappa is None: # planare: ha solo scala, nessuna mappa da applicare if mappa is None: # #95: senza mappa (manuale o planare) solo scala: frame grezzo, nessuna correzione
raise HTTPException(409, f"la calibrazione «{attiva}» non ha una mappa di correzione (planare)") dati = archivio.leggi(nome) or {}
corretto = calibrazione.raddrizza(frame, mappa) cw, ch = dati.get("image_width"), dati.get("image_height")
if corretto is None: # risoluzione diversa da quella calibrata
ch, cw = mappa.shape[:2]
alt, larg = frame.shape[:2] alt, larg = frame.shape[:2]
raise HTTPException(409, f"la calibrazione «{attiva}» è per immagini {cw}×{ch}, " if isinstance(cw, int) and isinstance(ch, int) and (cw, ch) != (larg, alt):
f"il frame è {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] alt, larg = corretto.shape[:2]
if corretto.dtype != np.uint8: # il JPEG vuole 8 bit if corretto.dtype != np.uint8: # il JPEG vuole 8 bit
corretto = _grigio8(corretto) corretto = _grigio8(corretto)
@@ -543,7 +638,7 @@ def create_app(folder: Path | str | None = None,
if not ok: if not ok:
raise HTTPException(500, "codifica JPEG fallita") raise HTTPException(500, "codifica JPEG fallita")
return Response(buf.tobytes(), media_type="image/jpeg", headers={ 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="")}) "X-Calibrazione": urllib.parse.quote(attiva, safe="")})
@app.get("/camera/parametri") @app.get("/camera/parametri")
+156
View File
@@ -0,0 +1,156 @@
"""Test di #94: «Blocca immagine» (scripts/camera_service.py) e riquadro vivo nella tab Calibrazione (PAGINA).
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
Sorgente finta che conta i grab: niente camera vera, niente ProgramData.
"""
import re
import numpy as np
import pytest
from test_calibrazione import _png, cartella, client, una_scacchiera # fixture e helper della calibrazione
from test_immagini_affiancate import _anteprima, _carica, _modulo
from test_pagina_acquisizione import PAGINA
__all__ = ["cartella", "client"] # le fixture importate restano usate
FINTA = "finta"
CAMPI = {"cols": "9", "rows": "6", "square_mm": "2"}
@pytest.fixture
def finta(client, monkeypatch):
"""Sorgente «finta»: il primo grab da' la scacchiera, i successivi un frame bianco (non calibrabile)."""
letture = []
scacchiera = una_scacchiera()
bianco = np.full(scacchiera.shape, 255, np.uint8)
class Finta:
def grab(self, timeout=1000):
letture.append(1)
return scacchiera.copy() if len(letture) == 1 else bianco.copy()
def close(self):
pass
monkeypatch.setitem(_modulo(client), "open_source", lambda *a, **k: Finta())
client.letture = letture
return client
def _blocca(c, nome=FINTA):
return c.post("/camera/blocca", json={"source": nome})
def _calcola(c, **extra):
return c.post("/camera/calibrate", data={"source": FINTA, **CAMPI, **extra})
# ---- servizio
def test_blocca_senza_frame_404(finta):
r = _blocca(finta)
assert r.status_code == 404 and r.json()["detail"] == "nessun frame: avviare l'anteprima o scattare"
def test_blocca_senza_source_400(finta):
assert finta.post("/camera/blocca", json={}).status_code == 400
assert finta.post("/camera/blocca").status_code == 400
def test_anteprima_poi_blocca_200_con_misure(finta):
assert _anteprima(finta, FINTA).status_code == 200
r = _blocca(finta)
alt, larg = una_scacchiera().shape[:2]
assert r.status_code == 200 and r.json() == {"source": FINTA, "larghezza": larg, "altezza": alt}
def test_calibrate_bloccata_usa_la_copia_senza_grab(finta):
assert _anteprima(finta, FINTA).status_code == 200 # frame 1: la scacchiera
assert _blocca(finta).status_code == 200
assert _anteprima(finta, FINTA).status_code == 200 # frame 2: bianco, il grezzo cambia ma la copia no
prima = len(finta.letture)
r = _calcola(finta, bloccata="true")
assert r.status_code == 200, r.text
assert r.json()["bloccata"] is True and r.json()["origine"] == FINTA
assert len(finta.letture) == prima # nessun grab per il calcolo
# senza il blocco il calcolo legge un frame nuovo (bianco): non calibrabile
assert _calcola(finta).status_code == 422
assert len(finta.letture) == prima + 1
def test_calibrate_normale_dice_bloccata_false(client):
r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI})
assert r.status_code == 200, r.text
assert r.json()["bloccata"] is False
def test_calibrate_bloccata_senza_copia_409(finta):
assert _anteprima(finta, FINTA).status_code == 200 # il grezzo c'e', ma non e' bloccato
r = _calcola(finta, bloccata="true")
assert r.status_code == 409 and r.json()["detail"] == "nessuna immagine bloccata su questa sorgente"
def test_sblocca_204_idempotente_e_toglie_la_copia(finta):
assert finta.delete("/camera/blocca", params={"source": FINTA}).status_code == 204 # niente da togliere
assert _anteprima(finta, FINTA).status_code == 200
assert _blocca(finta).status_code == 200
assert finta.delete("/camera/blocca", params={"source": FINTA}).status_code == 204
assert _calcola(finta, bloccata="true").status_code == 409
assert finta.delete("/camera/blocca", params={"source": FINTA}).status_code == 204
def test_rimuovere_l_immagine_toglie_la_copia_bloccata(client):
assert _carica(client, "foto.png", una_scacchiera()).status_code == 201
assert _anteprima(client, "foto.png").status_code == 200
assert _blocca(client, "foto.png").status_code == 200
assert client.delete("/camera/immagini", params={"nome": "foto.png"}).status_code == 204
assert _carica(client, "foto.png", una_scacchiera()).status_code == 201
r = client.post("/camera/calibrate", data={"source": "foto.png", "bloccata": "true", **CAMPI})
assert r.status_code == 409
# ---- pagina (contenuto statico)
def test_pagina_riquadro_e_casella_nella_tab_calibrazione():
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
assert {"immagineCalTab", "vuotoCalTab", "scattaCal", "bloccaImg", "msgBlocco"} <= ids
assert "Nessuna immagine: «Scatta» o avviare l'anteprima nella tab Acquisizione" in PAGINA
assert "Blocca immagine" in PAGINA
tab = PAGINA.split('id="pannelloCal"')[1].split('id="sezioneSalvate"')[0]
assert "immagineCalTab" in tab and "bloccaImg" in tab # nella sezione Calibrazione, non altrove
def test_pagina_scegli_tab_non_ferma_piu_il_ciclo():
assert "A.riprendi" not in PAGINA
scegli = PAGINA.split("function scegliTab")[1].split("$(\"tabAcq\").onclick")[0]
assert "fermaAnteprima" not in scegli and "avviaAnteprima" not in scegli
def test_pagina_calcola_manda_bloccata_e_non_ferma_il_ciclo():
calcola = PAGINA.split("async function calcola")[1].split("async function salvaNome")[0]
assert 'f.append("bloccata", "true")' in calcola
assert "if (bloccata)" in calcola and "else fermaAnteprima();" in calcola
assert "immagine bloccata" in calcola
assert "l'anteprima resta sospesa" not in PAGINA and "si può bloccare l'immagine" in PAGINA
def test_pagina_blocco_rotte_e_cambio_sorgente():
assert '"/camera/blocca"' in PAGINA and '"/camera/blocca?source="' in PAGINA
assert "method: \"DELETE\"" in PAGINA
cambio = PAGINA.split("function cambioSorgente")[1].split("// ---- Anteprima")[0]
assert "rilasciaBlocco()" in cambio
assert cambio.index("rilasciaBlocco()") < cambio.index("svuotaRiquadri()")
def test_pagina_blocco_attende_l_anteprima_in_volo():
# #94: il frame bloccato e' quello mostrato: flag A.bloccando, attesa di A.inVolo (20 ms, max 3 s), poi POST, flag tolto in finally
blocco = PAGINA.split('$("bloccaImg").onchange')[1].split('$("avvia").onclick')[0]
assert "A.bloccando++" in blocco and "A.bloccando--" in blocco and "finally" in blocco
assert "k < 150" in blocco and "pausa(20)" in blocco
assert blocco.index("A.inVolo") < blocco.index('fetch("/camera/blocca"')
ciclo = PAGINA.split("async function ciclo")[1].split("function avviaAnteprima")[0]
assert "while (A.bloccando)" in ciclo and ciclo.index("A.bloccando") < ciclo.index("unaAnteprima(1280)")
scatta = PAGINA.split("async function scatta")[1].split("// #94: «Blocca immagine»")[0]
assert "while (A.bloccando)" in scatta and scatta.index("A.bloccando") < scatta.index("unaAnteprima(4096)")
+2 -2
View File
@@ -169,7 +169,7 @@ def test_c2_immagine_caricata_senza_salvare(client):
files={"image": ("s.png", _png(una_scacchiera()), "image/png")}) files={"image": ("s.png", _png(una_scacchiera()), "image/png")})
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
corpo = r.json() 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["metodo"] == "planare" and corpo["distorsione"] is False # #84: con cols/rows nulla cambia
assert corpo["origine"] == "file:s.png" assert corpo["origine"] == "file:s.png"
assert corpo["px_per_mm"] == pytest.approx(20.0, rel=0.005) 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"}) r = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"})
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
corpo = r.json() 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["metodo"] == "densa" and corpo["distorsione"] is True
assert (corpo["cols"], corpo["rows"]) == (15, 11) assert (corpo["cols"], corpo["rows"]) == (15, 11)
assert corpo["px_per_mm"] == pytest.approx(1.0 / corpo["mm_per_px"]) assert corpo["px_per_mm"] == pytest.approx(1.0 / corpo["mm_per_px"])
+148
View File
@@ -0,0 +1,148 @@
"""Test di #95: calibrazione manuale (mm/px impostato a mano) in scripts/camera_service.py e nella PAGINA.
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
Sorgenti = immagini caricate in tmp_path: niente camera vera, niente ProgramData.
"""
import re
import numpy as np
import pytest
from test_calibrazione import _png, cartella, client # fixture della calibrazione
from test_immagini_affiancate import _anteprima, _calibrata, _carica
from test_pagina_acquisizione import PAGINA
__all__ = ["cartella", "client"] # le fixture importate restano usate
SRC = "banco.png" # 64x48
ALTRA = "grande.png" # 80x60, risoluzione diversa
@pytest.fixture
def sorgenti(client):
assert _carica(client, SRC, np.full((48, 64), 120, np.uint8)).status_code == 201
assert _carica(client, ALTRA, np.full((60, 80), 120, np.uint8)).status_code == 201
return client
def _manuale(c, nome="Manuale", mm=0.05, source=SRC, **extra):
return c.post("/camera/calibrazioni/manuale",
json={"source": source, "nome": nome, "mm_per_px": mm, **extra})
# ---- servizio
def test_manuale_201_con_scala_e_risoluzione_del_frame(sorgenti):
r = _manuale(sorgenti, mm=0.05)
assert r.status_code == 201, r.text
m = r.json()
assert m["nome"] == "Manuale" and m["metodo"] == "manuale"
assert m["px_per_mm"] == pytest.approx(20.0) and m["mm_per_px"] == pytest.approx(0.05)
assert (m["image_width"], m["image_height"]) == (64, 48)
assert m["origine"] == SRC and m["square_mm"] is None and m["attiva_su"] == [] # non la attiva
voci = sorgenti.get("/camera/calibrazioni").json()["calibrazioni"]
assert [v["nome"] for v in voci] == ["Manuale"]
def test_manuale_usa_il_frame_tenuto_se_c_e(sorgenti):
assert _anteprima(sorgenti, SRC).status_code == 200 # tiene il grezzo 64x48
r = _manuale(sorgenti)
assert r.status_code == 201 and (r.json()["image_width"], r.json()["image_height"]) == (64, 48)
@pytest.mark.parametrize("valore", [0, -1, 0.000001, 100.5, "abc", None, True])
def test_manuale_mm_non_valido_400(sorgenti, valore):
r = _manuale(sorgenti, mm=valore)
assert r.status_code == 400
assert "mm/px deve essere un numero positivo" in r.json()["detail"]
def test_manuale_senza_source_o_nome_400_e_sorgente_ignota_404(sorgenti):
assert sorgenti.post("/camera/calibrazioni/manuale", json={"nome": "x", "mm_per_px": 0.1}).status_code == 400
assert _manuale(sorgenti, nome=" ").status_code == 400
assert _manuale(sorgenti, source="nonesiste.png").status_code == 404
def test_manuale_409_nome_doppio_e_sovrascrivi(sorgenti):
assert _manuale(sorgenti, mm=0.05).status_code == 201
r = _manuale(sorgenti, mm=0.1)
assert r.status_code == 409 and "esiste già una calibrazione" in r.json()["detail"]
r = _manuale(sorgenti, mm=0.1, sovrascrivi=True)
assert r.status_code == 201 and r.json()["px_per_mm"] == pytest.approx(10.0)
assert _manuale(sorgenti, mm=0.1, sovrascrivi="si").status_code == 400
def test_attiva_manuale_ok_su_stessa_risoluzione_e_legge_px_per_mm(sorgenti):
assert _manuale(sorgenti, mm=0.05).status_code == 201
r = sorgenti.put("/camera/calibrazione-attiva", json={"source": SRC, "nome": "Manuale"})
assert r.status_code == 200, r.text
g = sorgenti.get("/camera/calibration", params={"source": SRC})
assert g.status_code == 200 and g.json()["px_per_mm"] == pytest.approx(20.0)
assert g.json()["nome"] == "Manuale" and g.json()["metodo"] == "manuale"
def test_attiva_manuale_409_su_risoluzione_diversa(sorgenti):
assert _manuale(sorgenti).status_code == 201
r = sorgenti.put("/camera/calibrazione-attiva", json={"source": ALTRA, "nome": "Manuale"})
assert r.status_code == 409 and "64×48" in r.json()["detail"]
def test_anteprima_calibrata_con_manuale_200_grezza(sorgenti):
assert _manuale(sorgenti).status_code == 201
assert sorgenti.put("/camera/calibrazione-attiva", json={"source": SRC, "nome": "Manuale"}).status_code == 200
assert _anteprima(sorgenti, SRC).status_code == 200
r = _calibrata(sorgenti, SRC)
assert r.status_code == 200, r.text
assert r.headers["X-Raddrizzato"] == "no" and r.headers["X-Calibrazione"] == "Manuale"
assert (r.headers["X-Larghezza"], r.headers["X-Altezza"]) == ("64", "48")
assert r.headers["content-type"] == "image/jpeg"
def test_anteprima_calibrata_manuale_409_se_il_frame_ha_altra_risoluzione(sorgenti):
assert _manuale(sorgenti).status_code == 201
assert sorgenti.put("/camera/calibrazione-attiva", json={"source": SRC, "nome": "Manuale"}).status_code == 200
assert _anteprima(sorgenti, ALTRA).status_code == 200
# stessa sorgente attiva, ma il frame tenuto cambia (immagine sostituita con altra misura)
assert _carica(sorgenti, SRC, np.full((60, 80), 9, np.uint8)).status_code == 201
assert _anteprima(sorgenti, SRC).status_code == 200
r = _calibrata(sorgenti, SRC)
assert r.status_code == 409 and "64×48" in r.json()["detail"]
# ---- pagina
def test_pagina_scheda_manuale_id_e_testi():
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
assert {"mmPx", "pxMmCalc", "nomeManuale", "salvaManuale", "msgManuale"} <= ids
tab = PAGINA.split('id="pannelloCal"')[1]
assert tab.index('id="sezioneCalibrazione"') < tab.index('id="sezioneManuale"') < tab.index('id="sezioneSalvate"')
assert "Calibrazione manuale" in PAGINA and "mm per pixel" in PAGINA
assert "salvata: per usarla «Carica» o CONFERMA" in PAGINA
assert "Sovrascrivi «" in PAGINA.split("async function salvaManuale")[1] # stessa conferma del Salva scacchiera
assert "/camera/calibrazioni/manuale" in PAGINA and "alert(" not in PAGINA and "confirm(" not in PAGINA
def test_pagina_manuale_senza_sorgente_pulsante_spento_con_nota():
stato = PAGINA.split("function statoManuale")[1].split("function aggiornaManuale")[0]
assert 'salvaManuale").disabled = !s' in stato and "Scegliere una sorgente" in stato
assert "statoManuale()" in PAGINA.split("async function aggiornaStato")[1].split("async function caricaCalibrazioni")[0]
def test_pagina_px_mm_calcolato_a_ogni_tasto():
assert '$("mmPx").oninput = aggiornaManuale' in PAGINA
assert '"= " + fmt(1 / v, 4) + " px/mm"' in PAGINA
def test_pagina_metodo_manuale_e_campi_null():
assert 'c.metodo === "densa" || c.metodo === "manuale" ? c.metodo : "planare"' in PAGINA
righe = PAGINA.split("function righe")[1].split("function tabella")[0]
assert 'd.metodo === "manuale"' in righe and "Lato dello scacco" in righe # la manuale ha il suo elenco, le altre il solito
assert 'v === null || v === undefined' in PAGINA.split("function fmt")[1].split("function fmtCasella")[0]
stato = PAGINA.split("async function aggiornaStato")[1].split("async function caricaCalibrazioni")[0]
assert "Number(d.square_mm) > 0" in stato
def test_pagina_calibrata_senza_raddrizzamento_dice_solo_scala():
assert "solo scala, nessuna correzione ottica" in PAGINA
assert 'r.headers.get("X-Raddrizzato") === "no"' in PAGINA
+32 -3
View File
@@ -106,6 +106,34 @@ def test_sources_con_immagini_e_senza_camera_file(client):
assert nomi.index("camera:folder") < nomi.index("a.png") assert nomi.index("camera:folder") < nomi.index("a.png")
def test_sources_camere_in_cache_e_aggiorna(client):
# #92: la ricerca camere (~10 s) si fa una volta e resta 60 s; aggiorna=true la rilancia; le immagini sono sempre fresche
g = _modulo(client)
originale = g["list_sources"]
chiamate = []
def finto(cartella, cameras=True, **kw):
if cameras:
chiamate.append(1)
return originale(cartella, cameras=cameras, **kw)
g["list_sources"] = finto
try:
client.get("/camera/sources") # se la ricerca d'avvio e' ancora in corso aspetta il suo lock
base = len(chiamate)
_carica(client, "nuova.png", una_scacchiera())
voci = client.get("/camera/sources").json()["sources"]
client.get("/camera/sources")
assert len(chiamate) == base # seconda e terza chiamata: nessuna nuova ricerca
assert voci[-1]["name"] == "nuova.png" # ma l'immagine appena caricata c'e'
client.get("/camera/sources", params={"aggiorna": "true"})
assert len(chiamate) == base + 1 # aggiorna=true la rilancia
client.get("/camera/sources")
assert len(chiamate) == base + 1 # e il risultato nuovo torna in cache
finally:
g["list_sources"] = originale
def test_sources_senza_immagini_non_crea_la_cartella(client): def test_sources_senza_immagini_non_crea_la_cartella(client):
voci = client.get("/camera/sources").json()["sources"] voci = client.get("/camera/sources").json()["sources"]
assert all(v["kind"] != "image" for v in voci) 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" 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()) _carica(client, PIANA, una_scacchiera())
_calibra_e_attiva(client, PIANA, "Piana", cols="9", rows="6") _calibra_e_attiva(client, PIANA, "Piana", cols="9", rows="6")
assert _anteprima(client, PIANA).status_code == 200 assert _anteprima(client, PIANA).status_code == 200
r = _calibrata(client, PIANA) r = _calibrata(client, PIANA)
assert r.status_code == 409 assert r.status_code == 200
assert r.json()["detail"] == "la calibrazione «Piana» non ha una mappa di correzione (planare)" assert r.headers["X-Raddrizzato"] == "no" and unquote(r.headers["X-Calibrazione"]) == "Piana"
+5 -5
View File
@@ -29,10 +29,10 @@ def test_offline_e_senza_finestre_del_browser():
def test_id_principali(): def test_id_principali():
ids = set(re.findall(r'id="([^"]+)"', PAGINA)) 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", "nitidezza", "nitidezzaMax", "saturati", "media", "dimensioni",
"parametri", "salvaParametri", "quadrato", "acquisisci", "scegliFile", "file", "parametri", "salvaParametri", "quadrato", "acquisisci", "caricaImmagine", "fileImmagine",
"salva", "stato", "statoSorgente"} # #91: carica/elimina sono ora per riga, nell'elenco "mostraCal", "salva", "stato", "statoSorgente"} # #91: carica/elimina sono ora per riga; #92: file e calibrata nell'Acquisizione
assert attesi <= ids, attesi - ids assert attesi <= ids, attesi - ids
# ogni id usato dal JS con $("...") esiste nel markup # ogni id usato dal JS con $("...") esiste nel markup
usati = set(re.findall(r'\$\("([^"]+)"\)', PAGINA)) usati = set(re.findall(r'\$\("([^"]+)"\)', PAGINA))
@@ -41,8 +41,8 @@ def test_id_principali():
def test_sezioni_e_testi_italiani(): def test_sezioni_e_testi_italiani():
for testo in ("Sorgente", "Anteprima", "Parametri camera", "Calibrazione", for testo in ("Sorgente", "Anteprima", "Parametri camera", "Calibrazione",
"Salva come predefiniti", "Scegli immagine...", "Questa sorgente non ha parametri regolabili", "Salva come predefiniti", "Carica immagine da file...", "Questa sorgente non ha parametri regolabili",
"Immagine grezza (senza correzione)"): "Attiva calibrazione"): # #92 #93
assert testo in PAGINA, testo assert testo in PAGINA, testo
assert "it-IT" in PAGINA # numeri con la virgola assert "it-IT" in PAGINA # numeri con la virgola
assert "Choose File" not in PAGINA assert "Choose File" not in PAGINA
+80 -14
View File
@@ -1,4 +1,4 @@
"""Test della pagina a due tab (#91): contenuto statico di PAGINA, senza servizio.""" """Test della pagina a due tab (#91, #92): contenuto statico di PAGINA, senza servizio."""
import importlib.util import importlib.util
import re 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) assert '"tmf.acq.tab"' in PAGINA # tab scelta ricordata (via memoria(), protetta)
def test_anteprima_fuori_dai_pannelli(): def test_sorgente_in_cima_poi_riquadri():
# l'anteprima sta nella colonna sinistra, prima dei due pannelli: visibile in entrambe le tab # #92: Sorgente, anteprima e parametri stanno nel pannello Acquisizione; Sorgente prima dei riquadri
i_ant = PAGINA.index('id="sezioneAnteprima"')
i_acq = PAGINA.index('id="pannelloAcq"') i_acq = PAGINA.index('id="pannelloAcq"')
i_cal = PAGINA.index('id="pannelloCal"') i_cal = PAGINA.index('id="pannelloCal"')
assert i_ant < i_acq < i_cal i_sorg = PAGINA.index('id="sezioneSorgente"')
# Sorgente e Parametri nel pannello Acquisizione, la calibrazione in quello Calibrazione i_ant = PAGINA.index('id="sezioneAnteprima"')
assert i_acq < PAGINA.index('id="sezioneSorgente"') < PAGINA.index('id="sezioneParametri"') < i_cal 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 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(): def test_id_del_pannello_calibrazione():
ids = set(re.findall(r'id="([^"]+)"', PAGINA)) ids = set(re.findall(r'id="([^"]+)"', PAGINA))
attesi = {"calSorgente", "origSorgente", "origFile", "scegliFile", "file", "quadrato", "acquisisci", attesi = {"calSorgente", "quadrato", "acquisisci", "nomeCal", "salva", "msgSalva", "calibrazioni",
"nomeCal", "salva", "msgSalva", "calibrazioni", "msgElenco", "stato", "risultato", "overlay"} "msgElenco", "stato", "risultato", "overlay",
"caricaImmagine", "fileImmagine", "rimuoviImmagine", "riquadroCal", "immagineCal", "mirinoCal"}
assert attesi <= ids, attesi - ids assert attesi <= ids, attesi - ids
usati = set(re.findall(r'\$\("([^"]+)"\)', PAGINA)) usati = set(re.findall(r'\$\("([^"]+)"\)', PAGINA))
assert usati <= ids, usati - ids assert usati <= ids, usati - ids
assert len(re.findall(r'id="([^"]+)"', PAGINA)) == len(ids) # nessun id doppio 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(): def test_testi_del_pannello():
for testo in ("Sorgente selezionata", "Immagine da file", "Scegli immagine...", "Calcola", for testo in ("Camera o immagine", "Carica immagine da file...", "Rimuovi immagine", "Calcola",
"Calibrazioni salvate", "si cambia nella tab Acquisizione", "risoluzione diversa", "Calibrazioni salvate", "risoluzione diversa", "Calibrata",
"Carica", "Elimina", "Scollega", "Sovrascrivi «", "Clic ancora", "Nome"): "Carica", "Elimina", "Scollega", "Sovrascrivi «", "Clic ancora", "Nome",
"calibrata: ", "non calibrata"):
assert testo in PAGINA, testo assert testo in PAGINA, testo
def test_rotte_del_contratto(): def test_rotte_del_contratto():
for rotta in ("/camera/calibrazioni", "/camera/calibrazione-attiva", "/camera/calibrate", 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 assert '"' + rotta in PAGINA, rotta
for parola in ("calcolo_id", "sovrascrivi", "method: \"PUT\"", "method: \"DELETE\"", "X-Larghezza", "X-Altezza"): for parola in ("calcolo_id", "sovrascrivi", "method: \"PUT\"", "method: \"DELETE\"", "X-Larghezza", "X-Altezza"):
assert parola in PAGINA, parola assert parola in PAGINA, parola
@@ -65,7 +86,7 @@ def test_nessuna_finestra_del_browser_e_offline():
def test_acquisizione_di_87_90_conservata(): def test_acquisizione_di_87_90_conservata():
# anteprima, indicatori, mirino, grezzo, parametri camera e ciclo a 100 ms restano # 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"): "Math.max(100 - (Date.now() - t0), 0)", "/camera/anteprima", "/camera/parametri"):
assert parola in PAGINA, parola assert parola in PAGINA, parola
@@ -75,3 +96,48 @@ def test_pagina_stretta():
# #91: elenco a schede, niente tabella larga ne' scorrimento orizzontale # #91: elenco a schede, niente tabella larga ne' scorrimento orizzontale
assert 'id="calibrazioni" class="elenco"' in PAGINA and "overflow-x" not in PAGINA assert 'id="calibrazioni" class="elenco"' in PAGINA and "overflow-x" not in PAGINA
assert "Lettura dei parametri..." 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
+82 -2
View File
@@ -1,4 +1,5 @@
"""MeasurementTec blueprint - recipe selection and measurement execution.""" """MeasurementTec blueprint - recipe selection and measurement execution."""
import json
import urllib.parse import urllib.parse
import requests as http_requests import requests as http_requests
@@ -337,6 +338,14 @@ def task_execute(task_id: int):
measurement_interval_minutes = recipe_resp.get("measurement_interval_minutes") measurement_interval_minutes = recipe_resp.get("measurement_interval_minutes")
allow_manual_input = bool(recipe_resp.get("allow_manual_input", True)) 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( return render_template(
"measure/task_execute.html", "measure/task_execute.html",
task=task_resp, task=task_resp,
@@ -345,7 +354,8 @@ def task_execute(task_id: int):
all_task_ids=all_task_ids, all_task_ids=all_task_ids,
measurement_interval_minutes=measurement_interval_minutes, measurement_interval_minutes=measurement_interval_minutes,
allow_manual_input=allow_manual_input, 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 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: def _station_camera(station_code: str | None) -> tuple[str, str] | None:
"""(camera_url, camera_source) of the station, None when it has no camera. """(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) 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 # #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. # 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"]) @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") @role_required("MeasurementTec")
def api_vision_grab(): def api_vision_grab():
"""Proxy: grab one frame from the current station's camera, answer a PNG.""" """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: if camera is None:
return jsonify({ return jsonify({
"error": True, "error": True,
@@ -11,6 +11,13 @@
* taskType, subtasks, labels. * taskType, subtasks, labels.
* Run context (lot, serial, production run) is read lazily through `ctx()`, * Run context (lot, serial, production run) is read lazily through `ctx()`,
* which the template binds to the parent task component. * 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() { function visionExecute() {
// Overlay colour per role (VisionSuite roles: reference, roi, found). // 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) imageSrc: '', // object URL of the acquired image (shown under the overlay)
ctx: () => ({}), // replaced by the template: {lot, serial, runId} 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. // Entry point shared with the future agent camera.
async acquire(blob) { async acquire(blob) {
if (!blob || this.busy) return; if (!blob || this.busy) return;
@@ -64,37 +190,52 @@ function visionExecute() {
// Station camera (#72): the Flask proxy answers a PNG, which goes through // Station camera (#72): the Flask proxy answers a PNG, which goes through
// acquire() like a picked file. Errors show as the execute ones do. // 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() { async grab() {
const cfg = window.__visionCfg || {}; 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.grabbing = true;
this.error = ''; this.error = '';
let blob = null; let blob = null;
try { try {
const csrf = document.querySelector('meta[name=csrf-token]')?.content || ''; 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, { const resp = await fetch(cfg.grabUrl, {
method: 'POST', headers: { 'X-CSRFToken': csrf }, method: 'POST', headers: { 'X-CSRFToken': csrf }, body: fd,
}); });
if (!resp.ok) { if (!resp.ok) {
const body = await resp.json().catch(() => ({})); const body = await resp.json().catch(() => ({}));
this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error'; if (gen === this.gen) this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error';
return; return false;
} }
blob = await resp.blob(); blob = await resp.blob();
} catch (e) { } catch (e) {
this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error'; if (gen === this.gen) this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
return; return false;
} finally { } 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. // 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) { onPick(ev) {
const f = ev.target.files && ev.target.files[0]; const f = ev.target.files && ev.target.files[0];
ev.target.value = ''; 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. // Image shown: the saved one from the server when available, else the local one.
@@ -534,9 +534,10 @@
<div x-show="isVisionTask" x-cloak <div x-show="isVisionTask" x-cloak
x-data="visionExecute()" x-data="visionExecute()"
x-init="ctx = () => ({ lot: lotNumber, serial: serialNumber, runId: productionRun ? productionRun.id : null })" 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"> <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. #} {# 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' : ''"> <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"> @change="onPick($event)" :disabled="busy">
</label> </label>
{% if camera_enabled %} {% 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()" <button type="button" class="btn btn-secondary text-sm gap-1.5" @click="grab()"
:class="(busy || grabbing) ? 'opacity-50 pointer-events-none' : ''" :class="(busy || grabbing) ? 'opacity-50 pointer-events-none' : ''"
:disabled="busy || grabbing"> :disabled="busy || grabbing">
<span>{{ _('Acquisisci dalla camera') }}</span> <span>{{ _('Acquisisci') }}</span>
</button> </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> <span x-show="grabbing" class="text-sm text-[var(--text-muted)]">{{ _('Acquisizione in corso...') }}</span>
{% endif %} {% endif %}
<span x-show="busy" class="text-sm text-[var(--text-muted)]">{{ _('Elaborazione in corso...') }}</span> <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="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"> {# #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) #}
<img x-ref="img" :src="shownImage" @load="drawOverlay()" alt="" class="block w-full h-auto"> <div x-show="shownImage" class="relative flex-1 min-h-0 border border-[var(--border-color)] rounded-lg overflow-hidden bg-black/5">
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full pointer-events-none"></canvas> <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>
<div x-show="!shownImage" class="text-sm text-[var(--text-muted)]"> <div x-show="!shownImage" class="text-sm text-[var(--text-muted)]">
{{ _('Nessuna immagine acquisita: premi «Acquisisci immagine».') }} {{ _('Nessuna immagine acquisita: premi «Acquisisci immagine».') }}
</div> </div>
</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)]"> <div class="text-sm font-bold text-[var(--text-primary)]">
{{ _('Esito') }}: <span x-text="(result && result.outcome) || '-'"></span> {{ _('Esito') }}: <span x-text="(result && result.outcome) || '-'"></span>
</div> </div>
@@ -1146,6 +1166,9 @@
window.__visionCfg = { window.__visionCfg = {
executeUrl: '{{ url_for("measure.api_vision_execute") }}', executeUrl: '{{ url_for("measure.api_vision_execute") }}',
grabUrl: '{{ url_for("measure.api_vision_grab") }}', 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) }}', imageUrlTpl: '{{ url_for("measure.api_vision_result_image", result_id=0) }}',
taskId: {{ task.id|tojson }}, taskId: {{ task.id|tojson }},
taskType: {{ (task.task_type or '')|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