Compare commits

...

17 Commits

Author SHA1 Message Date
Adriano 444dc0a6d1 backend: immagini BMP ammesse nelle rotte visione (Refs #99)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 15:46:58 +01:00
Adriano 4b1b23cfb6 Merge branch 'lavoro/daitarn-tmf-97c' into lavoro/daitarn-tmf-dxfcal 2026-10-24 19:33:04 +02:00
Adriano 4b57efc73c Merge branch 'lavoro/daitarn-tmf-97b' into lavoro/daitarn-tmf-dxfcal 2026-10-24 19:33:03 +02:00
Adriano 7c03f53000 Merge branch 'lavoro/daitarn-tmf-97a' into lavoro/daitarn-tmf-dxfcal 2026-10-24 19:33:03 +02:00
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 661b782bad backend: test camera e modo di analisi nel task, /vision/check (Refs #97)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 18:26:26 +02:00
Adriano 25f8b4d609 maker: camera e modo di analisi nel task visione (Refs #97)
Rotte /maker/api/camera/servizi e /maker/api/camera/sorgenti (url ammesso solo
se e' un servizio delle stazioni), sezione «Acquisizione» nell'editor task
(servizio, sorgente, analisi impulsiva/in linea) salvata come acquisition_json.

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

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

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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 16:41:18 +02:00
Adriano 4a48af2ae3 merge #92 pagina: sorgente in cima, grezza e calibrata affiancate 2026-10-24 16:28:00 +02:00
Adriano b879563738 merge #92 servizio: immagini come sorgenti, anteprima calibrata 2026-10-24 16:28:00 +02:00
Adriano 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
28 changed files with 2587 additions and 245 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
(/camera/calibrazioni, /camera/calibrazione-attiva); il calcolo non salva piu':
si salva dal calcolo_id con un nome.
#92: l'immagine da file e' una SORGENTE di acquisizione (POST /camera/immagini), non
un'opzione della calibrazione: la calibrazione usa sempre il frame grezzo della sorgente
scelta nella tab Acquisizione. Sorgente in cima alla tab; riquadri «Immagine» (grezza) e,
con la casella, «Calibrata» (/camera/anteprima/calibrata) affiancati; il ciclo anteprima
si sospende mentre la tab Calibrazione e' aperta.
Dipende da: niente. E' una stringa, cosi' il pacchetto compilato non ha file dati.
"""
@@ -41,6 +46,8 @@ PAGINA = r"""<!doctype html>
select:focus, input:focus, button:focus-visible { outline: 2px solid var(--accento); outline-offset: 1px; }
input[type=range] { width: 100%; accent-color: var(--accento); }
input[type=checkbox] { accent-color: var(--accento); }
.scelta-cal { margin-top: 8px; align-items: center; } /* #93 */
.scelta-cal select { flex: 1 1 200px; min-width: 0; } /* #93: nessuno scorrimento a 400 px */
.riga { display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-end; }
.nota { font-size: .82rem; color: var(--secondario); margin: 8px 0 0; }
button { padding: 7px 13px; font-size: .95rem; border: 0; border-radius: 6px; background: var(--accento); color: #fff;
@@ -61,10 +68,19 @@ PAGINA = r"""<!doctype html>
display: flex; align-items: center; justify-content: center; margin-top: 10px; overflow: hidden; }
.cornice { position: relative; display: inline-block; line-height: 0; max-width: 100%; }
.cornice img { max-width: 100%; max-height: calc(100vh - 330px); min-height: 200px; border: 0; margin: 0; }
#mirino { position: absolute; inset: 0; pointer-events: none;
.mirino { position: absolute; inset: 0; pointer-events: none; /* #92: classe, vale per i due riquadri */
background: linear-gradient(#fbbf24, #fbbf24) center / 2px 100% no-repeat,
linear-gradient(#fbbf24, #fbbf24) center / 100% 2px no-repeat; opacity: .85; }
#vuoto { padding: 30px 16px; color: var(--secondario); text-align: center; }
#vuoto, #vuotoCal, #vuotoCalTab { padding: 30px 16px; color: var(--secondario); text-align: center; }
/* #92: due riquadri affiancati (grezza | calibrata); in colonna sotto i 900 px */
.riquadri { display: grid; grid-template-columns: minmax(0, 1fr); gap: 14px; margin-top: 10px; }
.riquadri.doppio { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
.riquadro .titolo { font-size: .88rem; font-weight: 600; }
.riquadro .titolo span { font-weight: 400; font-size: .78rem; color: var(--secondario); margin-left: 6px; }
.riquadro .palco { margin-top: 4px; }
.sorgente-riga { display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-end; }
.sorgente-riga .campo-sorgente { flex: 1 1 260px; min-width: 0; }
.sorgente-riga select { width: 100%; }
.indicatori { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin-top: 10px; }
.indicatore { background: var(--sfondo); border: 1px solid var(--bordo); border-radius: 6px; padding: 8px 10px; }
.indicatore .nome { font-size: .78rem; color: var(--secondario); }
@@ -94,10 +110,9 @@ PAGINA = r"""<!doctype html>
.scheda-cal .azioni button { padding: 3px 10px; font-size: .82rem; }
.segno { display: inline-block; font-size: .72rem; padding: 0 6px; border-radius: 9px; background: var(--ok); color: #052e16; margin-left: 6px; }
.segno.diversa { background: var(--avviso); color: #422006; }
.origine label { display: inline-block; margin: 0 14px 4px 0; color: var(--testo); font-size: .92rem; }
input[type=radio] { accent-color: var(--accento); }
@media (max-width: 900px) {
.griglia { grid-template-columns: minmax(0, 1fr); }
.riquadri.doppio { grid-template-columns: minmax(0, 1fr); } /* #92: in colonna */
.cornice img { max-height: 60vh; }
}
@media (max-width: 500px) { /* #91: utilizzabile anche stretta (400 px) */
@@ -112,125 +127,169 @@ PAGINA = r"""<!doctype html>
<main>
<h1>Acquisizione camera</h1>
<!-- #91: due tab; la colonna dell'anteprima resta visibile in entrambe -->
<!-- #91: due tab; #92: l'anteprima sta nella tab Acquisizione e si sospende nella tab Calibrazione -->
<div class="tabs" role="tablist" aria-label="Sezioni della pagina">
<button id="tabAcq" type="button" role="tab" aria-selected="true" aria-controls="pannelloAcq">Acquisizione</button>
<button id="tabCal" type="button" role="tab" aria-selected="false" aria-controls="pannelloCal" tabindex="-1">Calibrazione</button>
</div>
<div class="griglia">
<div class="colonna">
<!-- #87: sezione Anteprima -->
<section class="scheda" id="sezioneAnteprima">
<h2>Anteprima</h2>
<div class="riga">
<button id="avvia">Avvia anteprima</button>
<button id="ferma" class="secondario" disabled>Ferma anteprima</button>
<button id="scatta" class="secondario">Scatta</button>
<label style="margin:0 0 6px"><input id="grezzo" type="checkbox"> Immagine grezza (senza correzione)</label>
<label style="margin:0 0 6px"><input id="mirinoCasella" type="checkbox"> Mirino centrale</label>
<div class="colonna" id="pannelloAcq" role="tabpanel" aria-labelledby="tabAcq">
<!-- #92: Sorgente in cima, a larghezza piena: camere, cartella e immagini caricate da file -->
<section class="scheda" id="sezioneSorgente">
<h2>Sorgente</h2>
<div class="sorgente-riga">
<div class="campo-sorgente">
<label for="sorgente">Camera o immagine</label>
<select id="sorgente"></select>
</div>
<div class="palco">
<div id="vuoto">Anteprima ferma. Premere «Avvia anteprima» o «Scatta».</div>
<div class="cornice" id="cornice" hidden>
<img id="immagine" alt="Anteprima della camera">
<div id="mirino" hidden></div>
</div>
</div>
<div class="indicatori">
<div class="indicatore">
<div class="nome">Nitidezza</div>
<div class="valore" id="nitidezza">-</div>
<div class="barra"><div id="barraNitidezza"></div></div>
<div class="piccolo">massimo visto: <span id="nitidezzaMax">-</span>
<button id="azzeraMax" class="secondario" style="padding:1px 7px;font-size:.75rem;margin-left:6px">Azzera</button></div>
</div>
<div class="indicatore">
<div class="nome">Saturati</div>
<div class="valore" id="saturati">-</div>
<div class="piccolo">pixel a 250 o più; da tenere sotto l'1 %</div>
</div>
<div class="indicatore">
<div class="nome">Livello medio</div>
<div class="valore" id="media">-</div>
<div class="piccolo">scala 0-255</div>
</div>
<div class="indicatore">
<div class="nome">Frame pieno</div>
<div class="valore" id="dimensioni">-</div>
<div class="piccolo" id="raddrizzato">&nbsp;</div>
</div>
</div>
<div id="msgAnteprima" class="messaggio"></div>
</section>
</div>
<button id="aggiornaSorgenti" class="secondario" type="button" title="Cerca di nuovo le camere collegate">Aggiorna</button>
<button id="caricaImmagine" class="secondario" type="button">Carica immagine da file...</button>
<input id="fileImmagine" type="file" accept="image/*" hidden>
<button id="rimuoviImmagine" class="secondario" type="button" hidden>Rimuovi immagine</button>
</div>
<div class="stato" id="statoSorgente">-</div>
<div id="msgSorgente" class="messaggio"></div>
</section>
<div class="colonna" id="pannelloAcq" role="tabpanel" aria-labelledby="tabAcq">
<!-- #87: sezione Sorgente -->
<section class="scheda" id="sezioneSorgente">
<h2>Sorgente</h2>
<label for="sorgente">Camera o cartella</label>
<select id="sorgente"></select>
<div class="stato" id="statoSorgente">-</div>
<div id="msgSorgente" class="messaggio"></div>
</section>
<!-- #87: sezione Anteprima; #92: barra, due riquadri affiancati (grezza | calibrata), indicatori dal frame grezzo -->
<section class="scheda" id="sezioneAnteprima">
<h2>Anteprima</h2>
<div class="riga">
<button id="avvia">Avvia anteprima</button>
<button id="ferma" class="secondario" disabled>Ferma anteprima</button>
<button id="scatta" class="secondario">Scatta</button>
<label style="margin:0 0 6px"><input id="mostraCal" type="checkbox"> Attiva calibrazione</label>
<label style="margin:0 0 6px"><input id="mirinoCasella" type="checkbox"> Mirino centrale</label>
</div>
<div class="riquadri" id="riquadri">
<div class="riquadro">
<div class="titolo">Immagine<span>grezza, senza correzione</span></div>
<div class="palco">
<div id="vuoto">Anteprima ferma. Premere «Avvia anteprima» o «Scatta».</div>
<div class="cornice" id="cornice" hidden>
<img id="immagine" alt="Anteprima grezza della sorgente">
<div id="mirino" class="mirino" hidden></div>
</div>
</div>
</div>
<div class="riquadro" id="riquadroCal" hidden>
<div class="titolo">Calibrata<span id="nomeCalibrata"></span></div>
<div class="palco">
<div id="vuotoCal">Premere «Avvia anteprima» o «Scatta».</div>
<div class="cornice" id="corniceCal" hidden>
<img id="immagineCal" alt="Anteprima della sorgente raddrizzata dalla calibrazione">
<div id="mirinoCal" class="mirino" hidden></div>
</div>
</div>
<!-- #93: scelta della calibrazione da attivare sulla sorgente, con CONFERMA -->
<div class="riga scelta-cal">
<select id="sceltaCal" aria-label="Calibrazione da attivare" disabled></select>
<button id="confermaCal" type="button" disabled>CONFERMA</button>
</div>
<div id="msgSceltaCal" class="messaggio"></div>
</div>
</div>
<div class="indicatori">
<div class="indicatore">
<div class="nome">Nitidezza</div>
<div class="valore" id="nitidezza">-</div>
<div class="barra"><div id="barraNitidezza"></div></div>
<div class="piccolo">massimo visto: <span id="nitidezzaMax">-</span>
<button id="azzeraMax" class="secondario" style="padding:1px 7px;font-size:.75rem;margin-left:6px">Azzera</button></div>
</div>
<div class="indicatore">
<div class="nome">Saturati</div>
<div class="valore" id="saturati">-</div>
<div class="piccolo">pixel a 250 o più; da tenere sotto l'1 %</div>
</div>
<div class="indicatore">
<div class="nome">Livello medio</div>
<div class="valore" id="media">-</div>
<div class="piccolo">scala 0-255</div>
</div>
<div class="indicatore">
<div class="nome">Frame pieno</div>
<div class="valore" id="dimensioni">-</div>
<div class="piccolo" id="raddrizzato">&nbsp;</div>
</div>
</div>
<div id="msgAnteprima" class="messaggio"></div>
</section>
<!-- #87: sezione Parametri camera -->
<section class="scheda" id="sezioneParametri">
<h2>Parametri camera</h2>
<div id="parametri"><div class="nota">-</div></div>
<div class="riga" id="salvaParametriRiga" hidden>
<button id="salvaParametri" class="secondario">Salva come predefiniti</button>
</div>
<div id="msgParametri" class="messaggio"></div>
</section>
</div>
<!-- #87: sezione Parametri camera -->
<section class="scheda" id="sezioneParametri">
<h2>Parametri camera</h2>
<div id="parametri"><div class="nota">-</div></div>
<div class="riga" id="salvaParametriRiga" hidden>
<button id="salvaParametri" class="secondario">Salva come predefiniti</button>
</div>
<div id="msgParametri" class="messaggio"></div>
</section>
</div>
<!-- #91: tab Calibrazione (colonna destra al posto di Sorgente e Parametri) -->
<div class="colonna" id="pannelloCal" role="tabpanel" aria-labelledby="tabCal" hidden>
<section class="scheda" id="sezioneCalibrazione">
<h2>Calibrazione</h2>
<div class="stato" style="margin:0 0 10px">Sorgente: <strong id="calSorgente">-</strong> (si cambia nella tab Acquisizione)</div>
<div class="origine">
<label for="origSorgente"><input id="origSorgente" name="origine" type="radio" value="sorgente" checked> Sorgente selezionata</label>
<label for="origFile"><input id="origFile" name="origine" type="radio" value="file"> Immagine da file</label>
</div>
<div class="riga" style="margin-top:4px">
<button id="scegliFile" class="secondario">Scegli immagine...</button>
<input id="file" type="file" accept="image/*" hidden>
<span id="nomeFile" class="nota" style="margin:0 0 6px">nessuna immagine scelta</span>
<!-- #91: tab Calibrazione; #92: sempre sul frame grezzo della sorgente scelta in Acquisizione, niente file qui -->
<div class="griglia" id="pannelloCal" role="tabpanel" aria-labelledby="tabCal" hidden>
<section class="scheda" id="sezioneCalibrazione">
<h2>Calibrazione</h2>
<div class="stato" style="margin:0 0 10px">Sorgente: <strong id="calSorgente">-</strong> — immagine grezza dalla tab Acquisizione</div>
<!-- #94: immagine viva (stesso frame dell'anteprima) e «Blocca immagine» per calcolare su quella -->
<div class="palco" style="margin-top:0">
<div id="vuotoCalTab">Nessuna immagine: «Scatta» o avviare l'anteprima nella tab Acquisizione</div>
<div class="cornice" id="corniceCalTab" hidden>
<img id="immagineCalTab" alt="Immagine grezza della sorgente">
</div>
</div>
<div class="riga" style="margin-top:8px">
<button id="scattaCal" class="secondario" type="button">Scatta</button>
<label style="margin:0 0 6px"><input id="bloccaImg" type="checkbox"> Blocca immagine</label>
</div>
<div id="msgBlocco" class="messaggio"></div>
<div class="riga" style="margin-top:10px">
<div><label for="quadrato">Lato dello scacco (mm)</label><input id="quadrato" type="text" inputmode="decimal" value="2" size="8"></div>
<button id="acquisisci" title="Acquisisci e calibra">Calcola</button>
</div>
<p class="nota">
Servono una scacchiera che riempie tutto il campo della camera, piana, ferma e ben illuminata, e il lato di uno scacco.
La griglia si trova da sola; il risultato corregge anche la distorsione dell'ottica.
Il frame è sempre quello grezzo della sorgente; l'anteprima continua anche qui e si può bloccare l'immagine per calcolare su quella.</p>
<div id="messaggio" class="messaggio"></div>
<div id="risultato" hidden style="margin-top:10px">
<strong>Risultato del calcolo</strong>
<div id="origineCalcolo" class="nota" style="margin:2px 0 6px"></div>
<table id="tabella"></table>
<div id="avviso" class="avviso"></div>
<div class="riga" style="margin-top:10px">
<div><label for="quadrato">Lato dello scacco (mm)</label><input id="quadrato" type="text" inputmode="decimal" value="2" size="8"></div>
<button id="acquisisci" title="Acquisisci e calibra">Calcola</button>
<div><label for="nomeCal">Nome</label><input id="nomeCal" type="text" maxlength="60" size="22" autocomplete="off"></div>
<button id="salva">Salva</button>
</div>
<p class="nota">
Servono una scacchiera che riempie tutto il campo della camera, piana, ferma e ben illuminata, e il lato di uno scacco.
La griglia si trova da sola; il risultato corregge anche la distorsione dell'ottica.
Calcolando dalla sorgente l'anteprima si sospende e poi riprende.</p>
<div id="messaggio" class="messaggio"></div>
<div id="msgSalva" class="messaggio"></div>
<img id="overlay" alt="Angoli trovati sulla scacchiera" style="max-width:100%;margin-top:8px;border:1px solid var(--bordo);border-radius:6px">
</div>
</section>
<div id="risultato" hidden style="margin-top:10px">
<strong>Risultato del calcolo</strong>
<div id="origineCalcolo" class="nota" style="margin:2px 0 6px"></div>
<table id="tabella"></table>
<div id="avviso" class="avviso"></div>
<div class="riga" style="margin-top:10px">
<div><label for="nomeCal">Nome</label><input id="nomeCal" type="text" maxlength="60" size="22" autocomplete="off"></div>
<button id="salva">Salva</button>
</div>
<div id="msgSalva" class="messaggio"></div>
<img id="overlay" alt="Angoli trovati sulla scacchiera" style="max-width:100%;margin-top:8px;border:1px solid var(--bordo);border-radius:6px">
</div>
</section>
<!-- #95: calibrazione manuale, mm per pixel impostato a mano (nessuna scacchiera) -->
<section class="scheda" id="sezioneManuale">
<h2>Calibrazione manuale</h2>
<p class="nota" style="margin-top:0">Senza scacchiera: si scrive a mano quanti millimetri vale un pixel. Vale solo come scala, nessuna correzione dell'ottica.</p>
<div class="riga" style="margin-top:8px">
<div><label for="mmPx">mm per pixel</label><input id="mmPx" type="text" inputmode="decimal" size="10" autocomplete="off"></div>
<div class="nota" id="pxMmCalc" style="margin:0 0 8px">= - px/mm</div>
</div>
<div class="riga" style="margin-top:6px">
<div><label for="nomeManuale">Nome</label><input id="nomeManuale" type="text" maxlength="60" size="22" autocomplete="off"></div>
<button id="salvaManuale">Salva</button>
</div>
<div id="notaManuale" class="nota"></div>
<div id="msgManuale" class="messaggio"></div>
</section>
<section class="scheda" id="sezioneSalvate">
<h2>Calibrazioni salvate</h2>
<div id="stato" class="stato">-</div>
<div id="calibrazioni" class="elenco"></div> <!-- #91: una scheda per calibrazione, niente tabella larga -->
<div id="msgElenco" class="messaggio"></div>
</section>
</div>
<section class="scheda" id="sezioneSalvate">
<h2>Calibrazioni salvate</h2>
<div id="stato" class="stato">-</div>
<div id="calibrazioni" class="elenco"></div> <!-- #91: una scheda per calibrazione, niente tabella larga -->
<div id="msgElenco" class="messaggio"></div>
</section>
</div>
</main>
@@ -243,6 +302,7 @@ var ultimo = null; // ultimo calcolo di calibrazione, per «Salva»
// numero all'italiana (virgola decimale); cifre = decimali fissi, omesso = fino a 4
function fmt(v, cifre) {
if (v === null || v === undefined || v === "") return "-"; // #95: campi null (rms_px, square_mm della manuale) = «-», non 0
var n = Number(v);
if (!isFinite(n)) return "-";
var o = cifre === undefined ? { maximumFractionDigits: 4 } : { minimumFractionDigits: cifre, maximumFractionDigits: cifre };
@@ -289,32 +349,123 @@ async function dettaglio(risposta) {
// ---- Sorgente
async function caricaSorgenti() {
// #92: scegli = nome da selezionare (immagine appena caricata); senza, l'ultima scelta ricordata
// #92: aggiorna = true forza la ricerca camere del servizio (camera appena collegata); di norma e' in cache
async function caricaSorgenti(scegli, aggiorna) {
var sel = $("sorgente");
var prima = sel.value;
sel.textContent = "";
try {
var r = await fetch("/camera/sources");
var r = await fetch("/camera/sources" + (aggiorna ? "?aggiorna=true" : ""));
var j = await r.json();
(j.sources || []).forEach(function (s) {
var o = document.createElement("option");
o.value = s.name;
o.textContent = s.label || s.name;
o.dataset.kind = s.kind || ""; // #92: «image» = immagine caricata da file
sel.appendChild(o);
});
} catch (e) { dire("msgSorgente", "Elenco delle sorgenti non disponibile.", "errore"); }
if (!sel.options.length) dire("msgSorgente", "Nessuna sorgente camera trovata.", "errore");
var ricordata = memoria("tmf.acq.sorgente"); // #87: ultima scelta ricordata
if (ricordata) {
for (var i = 0; i < sel.options.length; i++) if (sel.options[i].value === ricordata) sel.value = ricordata;
var voluta = scegli || (prima === "" ? memoria("tmf.acq.sorgente") : prima); // #87: ultima scelta ricordata
if (voluta) {
for (var i = 0; i < sel.options.length; i++) if (sel.options[i].value === voluta) sel.value = voluta;
}
cambioSorgente(false);
cambioSorgente(!!scegli);
}
// #92: la sorgente selezionata e' un'immagine caricata (si puo' rimuovere)?
function sorgenteImmagine() {
var sel = $("sorgente");
return sel.selectedIndex >= 0 && sel.options[sel.selectedIndex].dataset.kind === "image";
}
// #92: aggiunge (o aggiorna) la voce dell'immagine {source, label, kind} nella select e la seleziona
function aggiungiSorgente(j) {
var sel = $("sorgente");
var o = null;
for (var i = 0; i < sel.options.length; i++) if (sel.options[i].value === j.source) o = sel.options[i];
if (!o) { o = document.createElement("option"); o.value = j.source; sel.appendChild(o); }
o.textContent = j.label || j.source;
o.dataset.kind = j.kind || "image";
sel.value = j.source;
cambioSorgente(true);
}
// #92: «Aggiorna» = ricerca camere nuova (sul servizio puo' durare ~10 s)
$("aggiornaSorgenti").onclick = async function () {
$("aggiornaSorgenti").disabled = true;
dire("msgSorgente", "Ricerca delle camere in corso...", "");
await caricaSorgenti(undefined, true);
dire("msgSorgente", "Elenco aggiornato.", "ok");
$("aggiornaSorgenti").disabled = false;
};
// #92: «Carica immagine da file...» -> POST /camera/immagini, poi la nuova immagine diventa la sorgente
$("caricaImmagine").onclick = function () { $("fileImmagine").click(); };
$("fileImmagine").onchange = async function () {
var file = $("fileImmagine").files[0];
$("fileImmagine").value = ""; // lo stesso file si puo' ricaricare
if (!file) return;
var f = new FormData();
f.append("image", file);
$("caricaImmagine").disabled = true;
dire("msgSorgente", "Caricamento di «" + file.name + "»...", "");
try {
var r = await fetch("/camera/immagini", { method: "POST", body: f });
if (!r.ok) { dire("msgSorgente", await dettaglio(r), "errore"); return; }
var j = await r.json();
aggiungiSorgente(j); // #92: la ricerca camere puo' durare ~10 s: la voce nuova si aggiunge senza rileggere l'elenco
dire("msgSorgente", "Immagine «" + j.source + "» caricata: premere «Scatta» per vederla.", "ok");
} catch (e) {
dire("msgSorgente", "Servizio non raggiungibile.", "errore");
} finally {
$("caricaImmagine").disabled = false;
}
};
// #92: «Rimuovi immagine» con doppio clic entro 4 s (niente finestre del browser)
var R = { timer: null };
function annullaRimuovi() {
clearTimeout(R.timer);
R.timer = null;
$("rimuoviImmagine").textContent = "Rimuovi immagine";
}
$("rimuoviImmagine").onclick = async function () {
var nome = $("sorgente").value;
if (!nome || !sorgenteImmagine()) return;
if (R.timer === null) {
$("rimuoviImmagine").textContent = "Confermi? Clic ancora";
R.timer = setTimeout(annullaRimuovi, 4000);
return;
}
annullaRimuovi();
fermaAnteprima(); // la sorgente sta per sparire: niente richieste in volo su di lei
while (A.inVolo) await pausa(50);
try {
var r = await fetch("/camera/immagini?nome=" + encodeURIComponent(nome), { method: "DELETE" });
if (!r.ok) { dire("msgSorgente", await dettaglio(r), "errore"); return; }
var sel = $("sorgente"); // #92: voce tolta in locale, senza rileggere l'elenco dal servizio
sel.removeChild(sel.options[sel.selectedIndex]);
if (sel.options.length) sel.selectedIndex = 0;
cambioSorgente(true);
dire("msgSorgente", "Immagine «" + nome + "» rimossa.", "ok");
} catch (e) {
dire("msgSorgente", "Servizio non raggiungibile.", "errore");
}
};
// #87: cambiando sorgente si azzerano gli indicatori e si rileggono parametri e calibrazione
function cambioSorgente(salva) {
if (salva) memoria("tmf.acq.sorgente", $("sorgente").value);
var era = A.attiva;
fermaAnteprima();
if (A.bloccata) rilasciaBlocco(); // #94: casella spenta e DELETE sulla sorgente vecchia (prima di svuotare i riquadri)
azzeraIndicatori();
svuotaRiquadri(); // #92: il frame vecchio non e' della nuova sorgente
A.calAttiva = null; A.bloccoCal = false; // #92: stato calibrazione della nuova sorgente ancora da leggere
annullaRimuovi();
$("rimuoviImmagine").hidden = !sorgenteImmagine();
dire("msgAnteprima", "");
dire("msgElenco", ""); // #91
aggiornaStato();
@@ -324,7 +475,8 @@ function cambioSorgente(salva) {
// ---- Anteprima (#87)
var A = { attiva: false, gen: 0, inVolo: false, url: null, nitMax: 0, ris: null }; // #91: ris = {w, h} dell'ultimo frame
// #91: ris = {w, h} dell'ultimo frame; #92: calAttiva = la sorgente ha una calibrazione attiva (null = non nota), bloccoCal = un 409 ha fermato le richieste calibrata; urlCal/seqCal = riquadro calibrata, bloccata/sorgenteBloccata/urlBlocco/seqBlocco = #94 immagine bloccata per il calcolo (il riquadro della tab Calibrazione non segue piu' il ciclo), lato = ultimo lato_max
var A = { attiva: false, gen: 0, inVolo: false, url: null, urlCal: null, seqCal: 0, bloccata: false, bloccando: 0, sorgenteBloccata: null, urlBlocco: null, seqBlocco: 0, lato: 1280, calAttiva: null, bloccoCal: false, nitMax: 0, ris: null };
function azzeraIndicatori() {
A.nitMax = 0;
@@ -345,9 +497,74 @@ function mostraImmagine(blob) {
var vecchio = A.url;
A.url = URL.createObjectURL(blob);
$("immagine").src = A.url;
if (vecchio) URL.revokeObjectURL(vecchio); // niente memoria che cresce a ogni frame
if (vecchio && vecchio !== A.urlBlocco) URL.revokeObjectURL(vecchio); // niente memoria che cresce a ogni frame; #94: non quello bloccato
$("cornice").hidden = false;
$("vuoto").hidden = true;
if (!A.bloccata) mostraInTabCal(A.url); // #94: lo stesso blob URL va anche nella tab Calibrazione, salvo immagine bloccata
}
// #94: riquadro «immagine viva» della tab Calibrazione (url null = vuoto)
function mostraInTabCal(url) {
$("immagineCalTab").src = url || "";
$("corniceCalTab").hidden = !url;
$("vuotoCalTab").hidden = !!url;
}
// #92: riquadro «Calibrata»: l'immagine raddrizzata oppure il motivo per cui non c'e' (404/409 del servizio)
function mostraCalibrata(blob, nome, soloScala) {
var vecchio = A.urlCal;
A.urlCal = URL.createObjectURL(blob);
$("immagineCal").src = A.urlCal;
if (vecchio) URL.revokeObjectURL(vecchio);
$("corniceCal").hidden = false;
$("vuotoCal").hidden = true;
$("nomeCalibrata").textContent = (nome ? "«" + nome + "»" : "") + (soloScala ? " — solo scala, nessuna correzione ottica" : ""); // #95: senza mappa il frame e' grezzo
}
function messaggioCalibrata(testo) {
$("corniceCal").hidden = true;
$("vuotoCal").hidden = false;
$("vuotoCal").textContent = testo;
$("nomeCalibrata").textContent = "";
}
// #92: cambio sorgente: via i due frame, nessuna immagine di un'altra sorgente
function svuotaRiquadri() {
A.seqCal++; // una risposta calibrata ancora in volo non vale piu'
$("cornice").hidden = true;
$("vuoto").hidden = false;
mostraInTabCal(null); // #94: svuotato anche il riquadro della tab Calibrazione
messaggioCalibrata("Premere «Avvia anteprima» o «Scatta».");
}
// #92: raddrizza l'ULTIMO frame grezzo tenuto dal servizio (nessuna lettura nuova); chiamata dopo ogni anteprima, dopo Carica/Scollega e alla casella
async function aggiornaCalibrata(lato) {
var sorgente = $("sorgente").value;
if (!$("mostraCal").checked || !sorgente) return;
if (A.calAttiva === false) { // #92: lo stato dice che non c'e' calibrazione: niente richiesta al servizio
A.seqCal++;
messaggioCalibrata("nessuna calibrazione attiva su questa sorgente");
return;
}
if (A.bloccoCal) return; // #92: dopo un 409 (risoluzione diversa, planare) si richiede solo dopo Carica/Scollega o cambio sorgente
var n = ++A.seqCal;
try {
var r = await fetch("/camera/anteprima/calibrata", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source: sorgente, lato_max: lato || A.lato })
});
if (n !== A.seqCal) return; // risposta superata da una piu' recente o da un cambio sorgente
if (!r.ok) { // l'anteprima grezza continua
if (r.status === 409) A.bloccoCal = true; // #92: inutile richiedere a ogni frame
messaggioCalibrata(await dettaglio(r));
return;
}
var nome = "";
try { nome = decodeURIComponent(r.headers.get("X-Calibrazione") || ""); } catch (e) { /* intestazione non decodificabile: nessun nome */ }
var blob = await r.blob();
if (n !== A.seqCal) return;
mostraCalibrata(blob, nome, r.headers.get("X-Raddrizzato") === "no"); // #95
} catch (e) {
if (n === A.seqCal) messaggioCalibrata("Servizio non raggiungibile.");
}
}
function aggiornaIndicatori(h) {
@@ -372,8 +589,7 @@ function aggiornaIndicatori(h) {
var nw = parseInt(w, 10), na = parseInt(a, 10);
if (!A.ris || A.ris.w !== nw || A.ris.h !== na) { A.ris = { w: nw, h: na }; segnaRisoluzioni(); }
}
var r = h.get("X-Raddrizzato");
$("raddrizzato").textContent = r === "si" ? "raddrizzata dalla calibrazione" : r === "no" ? "non raddrizzata" : " ";
$("raddrizzato").textContent = "frame grezzo"; // #92: l'indicatore viene sempre dal frame grezzo
}
// una richiesta di anteprima; ritorna "ok" | "errore" | "fatale" (errore che non passa riprovando)
@@ -381,12 +597,13 @@ async function unaAnteprima(lato) {
var sorgente = $("sorgente").value;
if (!sorgente) { dire("msgAnteprima", "Scegliere una sorgente.", "errore"); return "fatale"; }
var g = A.gen;
A.lato = lato;
A.inVolo = true;
try {
var r = await fetch("/camera/anteprima", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source: sorgente, grezzo: $("grezzo").checked, lato_max: lato, timeout_ms: 5000 })
body: JSON.stringify({ source: sorgente, grezzo: true, lato_max: lato, timeout_ms: 5000 }) // #92: il riquadro «Immagine» e' sempre grezzo
});
if (!r.ok) {
var d = await dettaglio(r);
@@ -398,6 +615,7 @@ async function unaAnteprima(lato) {
mostraImmagine(blob);
aggiornaIndicatori(r.headers);
dire("msgAnteprima", "");
await aggiornaCalibrata(lato); // #92: stesso frame, raddrizzato; resta in volo finche' non risponde
return "ok";
} catch (e) {
if (g === A.gen) dire("msgAnteprima", "Servizio non raggiungibile.", "errore");
@@ -411,6 +629,8 @@ async function unaAnteprima(lato) {
async function ciclo(g) {
while (A.inVolo) await pausa(50);
while (A.attiva && g === A.gen) {
while (A.bloccando) await pausa(20); // #94: durante «Blocca immagine» nessuna nuova anteprima
if (!A.attiva || g !== A.gen) return;
var t0 = Date.now();
var esito = await unaAnteprima(1280);
if (g !== A.gen) return;
@@ -436,18 +656,83 @@ function fermaAnteprima() {
// «Scatta»: ferma il ciclo e mostra un frame solo, alla risoluzione piena (fino a 4096 px)
async function scatta() {
fermaAnteprima();
$("scatta").disabled = true;
$("scatta").disabled = $("scattaCal").disabled = true; // #94: anche il «Scatta» della tab Calibrazione
dire("msgAnteprima", "Scatto in corso...", "");
while (A.inVolo) await pausa(50);
while (A.bloccando) await pausa(20); // #94: niente scatto mentre si blocca l'immagine
await unaAnteprima(4096);
$("scatta").disabled = false;
$("scattaCal").disabled = A.bloccata; // #94: con l'immagine bloccata resta spento
}
// #94: «Blocca immagine»: il servizio tiene una copia del frame grezzo e «Calcola» usa quella
function sbloccaLocale() { // casella spenta, il riquadro della tab Calibrazione torna a seguire il ciclo
A.bloccata = false;
A.sorgenteBloccata = null;
A.seqBlocco++; // una risposta di blocco ancora in volo non vale piu'
var b = A.urlBlocco;
A.urlBlocco = null;
if (b && b !== A.url) URL.revokeObjectURL(b);
$("bloccaImg").checked = false;
$("scattaCal").disabled = $("scatta").disabled;
mostraInTabCal($("cornice").hidden ? null : A.url);
}
function rilasciaBlocco() { // DELETE idempotente: l'esito non conta
var s = A.sorgenteBloccata;
sbloccaLocale();
dire("msgBlocco", "");
if (s) fetch("/camera/blocca?source=" + encodeURIComponent(s), { method: "DELETE" }).catch(function () { /* niente da fare */ });
}
$("bloccaImg").onchange = async function () {
if (!this.checked) { rilasciaBlocco(); return; }
var sorgente = $("sorgente").value;
if (!sorgente || $("cornice").hidden) {
$("bloccaImg").checked = false;
dire("msgBlocco", "Nessuna immagine da bloccare: «Scatta» o avviare l'anteprima.", "errore");
return;
}
A.bloccata = true; // subito: il riquadro smette di seguire il ciclo
A.sorgenteBloccata = sorgente;
A.urlBlocco = null; // #94: lo si fissa dopo l'attesa, sul frame davvero mostrato
var n = ++A.seqBlocco;
$("scattaCal").disabled = true;
dire("msgBlocco", "Blocco in corso...", "");
A.bloccando++; // #94: il ciclo e «Scatta» non chiedono nuove anteprime finche' la copia non e' fatta
try {
for (var k = 0; A.inVolo && k < 150; k++) await pausa(20); // #94: attende l'anteprima in volo (max 3 s): il frame copiato e' l'ultimo mostrato
if (n !== A.seqBlocco) return; // spenta o cambiata sorgente durante l'attesa: niente copia
if (A.inVolo) { sbloccaLocale(); dire("msgBlocco", "Anteprima ancora in corso: riprovare.", "errore"); return; }
A.urlBlocco = A.url; // #94: il frame mostrato in questo momento e' quello che il servizio copia
mostraInTabCal(A.url);
var r = await fetch("/camera/blocca", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ source: sorgente }) });
if (n !== A.seqBlocco) { // spenta o cambiata sorgente nel frattempo: la copia appena fatta si toglie
if (r.ok) fetch("/camera/blocca?source=" + encodeURIComponent(sorgente), { method: "DELETE" }).catch(function () { /* niente da fare */ });
return;
}
if (!r.ok) { var d = await dettaglio(r); sbloccaLocale(); dire("msgBlocco", d, "errore"); return; }
var b = await r.json();
dire("msgBlocco", "Immagine bloccata " + b.larghezza + "×" + b.altezza + ": «Calcola» usa questa.", "ok");
} catch (e) {
if (n === A.seqBlocco) { sbloccaLocale(); dire("msgBlocco", "Servizio non raggiungibile.", "errore"); }
} finally {
A.bloccando--; // #94: anche su errore o uscita anticipata
}
};
$("avvia").onclick = avviaAnteprima;
$("ferma").onclick = fermaAnteprima;
$("scatta").onclick = scatta;
$("scattaCal").onclick = scatta; // #94
$("azzeraMax").onclick = function () { A.nitMax = 0; $("nitidezzaMax").textContent = "-"; $("barraNitidezza").style.width = "0"; };
$("mirinoCasella").onchange = function () { $("mirino").hidden = !$("mirinoCasella").checked; };
// #92: il mirino vale per entrambi i riquadri; #93: la casella «Attiva calibrazione» apre il riquadro e raddrizza il frame gia' tenuto
$("mirinoCasella").onchange = function () { $("mirino").hidden = $("mirinoCal").hidden = !$("mirinoCasella").checked; };
function mostraRiquadroCal() {
var si = $("mostraCal").checked;
$("riquadroCal").hidden = !si;
$("riquadri").className = "riquadri" + (si ? " doppio" : "");
if (si) aggiornaCalibrata();
}
$("mostraCal").onchange = mostraRiquadroCal;
// ---- Parametri camera (#87)
@@ -676,6 +961,8 @@ function scegliTab(nome, salva) {
$("tabCal").tabIndex = cal ? 0 : -1;
$("pannelloAcq").hidden = cal;
$("pannelloCal").hidden = !cal;
// #94: il ciclo anteprima non si sospende piu' aprendo la tab Calibrazione (come faceva #92)
if (!cal && salva) aggiornaCalibrata(); // il riquadro calibrata riflette la calibrazione attiva di adesso
if (salva) { memoria("tmf.acq.tab", nome); if (cal) caricaCalibrazioni(); } // l'elenco si rilegge entrando
}
$("tabAcq").onclick = function () { scegliTab("acq", true); };
@@ -692,6 +979,11 @@ $("tabCal").onclick = function () { scegliTab("cal", true); };
// ---- Calibrazione (logica di #83 e #84; #87: lato precompilato, data italiana, conferma in pagina)
function righe(d) {
if (d.metodo === "manuale") return [ // #95: solo scala impostata a mano, niente griglia, residui o lato dello scacco
["Scala", fmt(d.px_per_mm, 4) + " px/mm (" + fmt(d.mm_per_px, 5) + " mm/px)"],
["Metodo", "manuale (mm/px impostato a mano, nessuna correzione della distorsione)"],
["Immagine", fmt(d.image_width, 0) + " x " + fmt(d.image_height, 0) + " px"]
];
return [
["Scala", fmt(d.px_per_mm, 4) + " px/mm (" + fmt(d.mm_per_px, 5) + " mm/px)"],
["Metodo", d.metodo !== "densa" ? "planare (senza correzione della distorsione)" : "densa (immagine raddrizzata)"],
@@ -748,6 +1040,7 @@ async function aggiornaStato() {
var stato = $("stato");
annullaConferma();
$("calSorgente").textContent = sorgente ? $("sorgente").options[$("sorgente").selectedIndex].textContent : "-"; // #91
statoManuale(); // #95: «Salva» della calibrazione manuale spento senza sorgente
if (!sorgente) { stato.textContent = "-"; $("statoSorgente").textContent = "-"; caricaCalibrazioni(); return; }
try {
var r = await fetch("/camera/calibration?source=" + encodeURIComponent(sorgente));
@@ -755,10 +1048,12 @@ async function aggiornaStato() {
if (r.status === 404) {
stato.textContent = "Sorgente non calibrata.";
$("statoSorgente").textContent = "non calibrata";
A.calAttiva = false; // #92
} else if (!r.ok) {
stato.textContent = await dettaglio(r);
} else {
var d = await r.json();
A.calAttiva = true; // #92
stato.textContent = "";
var titolo = document.createElement("div");
titolo.textContent = "Calibrazione attiva: " + (d.nome ? "«" + d.nome + "»" : "(senza nome)");
@@ -770,8 +1065,8 @@ async function aggiornaStato() {
stato.appendChild(titolo);
stato.appendChild(t);
stato.appendChild(quando);
$("statoSorgente").textContent = "calibrata: " + fmt(d.px_per_mm, 2) + " px/mm";
if (d.square_mm) $("quadrato").value = fmtCasella(d.square_mm); // #87: parte dal lato gia' usato
$("statoSorgente").textContent = "calibrata: " + (d.nome || "(senza nome)") + " · " + fmt(d.px_per_mm, 2) + " px/mm"; // #92
if (Number(d.square_mm) > 0) $("quadrato").value = fmtCasella(d.square_mm); // #87: parte dal lato gia' usato
}
} catch (e) {
stato.textContent = "Servizio non raggiungibile.";
@@ -810,6 +1105,7 @@ function disegnaElenco() {
annullaConferma();
t.textContent = "";
C.celle = [];
disegnaSceltaCal(); // #93: la combo sotto la calibrata segue l'elenco, anche se vuoto
if (!C.elenco.length) {
var vuota = document.createElement("div");
vuota.className = "nota";
@@ -841,7 +1137,7 @@ function disegnaElenco() {
riga2.textContent = [
c.image_width && c.image_height ? fmt(c.image_width, 0) + "×" + fmt(c.image_height, 0) : "-",
fmt(c.px_per_mm, 3) + " px/mm",
c.metodo === "densa" ? "densa" : "planare",
c.metodo === "densa" || c.metodo === "manuale" ? c.metodo : "planare", // #95: «manuale» mostrato come tale
c.calibrated_at ? dataItaliana(c.calibrated_at) : "-",
descriviOrigine(c.origine)
].join(" · ");
@@ -850,7 +1146,7 @@ function disegnaElenco() {
var bc = bottone("Carica", "", function () { caricaNome(c.nome); });
bc.disabled = !$("sorgente").value;
az.appendChild(bc);
if (attiva) az.appendChild(bottone("Scollega", "secondario", scollega));
if (attiva) az.appendChild(bottone("Scollega", "secondario", function () { scollega("msgElenco"); })); // #93: senza l'evento come parametro
az.appendChild(bottone("Elimina", "secondario", function (e) { eliminaNome(c.nome, e.currentTarget); }));
sc.appendChild(riga1);
sc.appendChild(riga2);
@@ -865,39 +1161,80 @@ function segnaRisoluzioni() {
(C.celle || []).forEach(function (c) {
c.span.hidden = !(A.ris && c.w > 0 && c.h > 0 && (A.ris.w !== c.w || A.ris.h !== c.h));
});
// #93: stesso avviso nelle voci della combo; si cambia solo il testo, cosi' scelta e menu aperto restano
var sel = $("sceltaCal");
for (var i = 0; i < sel.options.length; i++) if (sel.options[i].value) sel.options[i].textContent = testoVoceCal(sel.options[i].dataset);
}
// #93: testo di una voce della combo: «nome · W×H · px/mm», con «risoluzione diversa» se il frame noto non combacia
function testoVoceCal(d) {
var w = Number(d.w), h = Number(d.h);
var t = d.nome + " · " + (w && h ? fmt(w, 0) + "×" + fmt(h, 0) : "-") + " · " + fmt(Number(d.px), 3) + " px/mm";
if (A.ris && w > 0 && h > 0 && (A.ris.w !== w || A.ris.h !== h)) t += " — risoluzione diversa";
return t;
}
// #93: ridisegna la combo (ogni ricarica dell'elenco); preselezionata la calibrazione attiva, senza sorgente tutto spento
function disegnaSceltaCal() {
var sel = $("sceltaCal");
sel.textContent = "";
var nessuna = document.createElement("option");
nessuna.value = "";
nessuna.textContent = "— nessuna (non calibrata) —";
sel.appendChild(nessuna);
(C.elenco || []).forEach(function (c) {
var o = document.createElement("option");
o.value = c.nome;
o.dataset.nome = c.nome; o.dataset.w = c.image_width || 0; o.dataset.h = c.image_height || 0; o.dataset.px = c.px_per_mm || 0;
o.textContent = testoVoceCal(o.dataset);
sel.appendChild(o);
});
sel.value = C.attiva !== null && C.attiva !== undefined ? C.attiva : "";
if (sel.selectedIndex < 0) sel.selectedIndex = 0;
sel.disabled = $("confermaCal").disabled = !$("sorgente").value;
}
// «Carica»: la calibrazione diventa quella attiva della sorgente. Un errore (es. risoluzione diversa) non ferma l'anteprima.
async function caricaNome(nome) {
async function caricaNome(nome, idMsg) {
idMsg = idMsg || "msgElenco"; // #93: il messaggio puo' stare sotto la calibrata (msgSceltaCal)
var sorgente = $("sorgente").value;
if (!sorgente) { dire("msgElenco", "Scegliere una sorgente nella tab Acquisizione.", "errore"); return; }
dire("msgElenco", "Caricamento di «" + nome + "»...", "");
if (!sorgente) { dire(idMsg, "Scegliere una sorgente nella tab Acquisizione.", "errore"); return; }
dire(idMsg, "Caricamento di «" + nome + "»...", "");
try {
var r = await fetch("/camera/calibrazione-attiva", { method: "PUT", headers: JSON_H, body: JSON.stringify({ source: sorgente, nome: nome }) });
if (sorgente !== $("sorgente").value) return;
if (!r.ok) { dire("msgElenco", await dettaglio(r), "errore"); return; }
dire("msgElenco", "Calibrazione «" + nome + "» caricata sulla sorgente.", "ok");
$("raddrizzato").textContent = $("grezzo").checked ? "non raddrizzata" : "raddrizzata dalla calibrazione"; // il prossimo frame conferma
aggiornaStato();
if (!r.ok) { dire(idMsg, await dettaglio(r), "errore"); disegnaSceltaCal(); return; } // #93: la combo torna sull'attiva
dire(idMsg, "Calibrazione «" + nome + "» caricata sulla sorgente.", "ok");
A.bloccoCal = false; // #92
await aggiornaStato(); // prima lo stato nuovo (rilegge anche l'elenco, #93: schede e combo allineate), poi il riquadro calibrata
aggiornaCalibrata();
} catch (e) {
dire("msgElenco", "Servizio non raggiungibile.", "errore");
dire(idMsg, "Servizio non raggiungibile.", "errore");
disegnaSceltaCal(); // #93
}
}
// «Scollega»: la sorgente torna senza calibrazione, la libreria resta
async function scollega() {
async function scollega(idMsg) {
idMsg = idMsg || "msgElenco"; // #93
var sorgente = $("sorgente").value;
if (!sorgente) return;
try {
var r = await fetch("/camera/calibration?source=" + encodeURIComponent(sorgente), { method: "DELETE" });
dire("msgElenco", r.ok ? "Calibrazione scollegata dalla sorgente (resta fra le salvate)." : await dettaglio(r), r.ok ? "ok" : "errore");
if (r.ok) $("raddrizzato").textContent = "non raddrizzata";
dire(idMsg, r.ok ? "Calibrazione scollegata dalla sorgente (resta fra le salvate)." : await dettaglio(r), r.ok ? "ok" : "errore");
} catch (e) {
dire("msgElenco", "Servizio non raggiungibile.", "errore");
dire(idMsg, "Servizio non raggiungibile.", "errore");
}
aggiornaStato();
A.bloccoCal = false; // #92
await aggiornaStato(); // #93: rilegge anche l'elenco, quindi la combo torna sull'attiva anche dopo un errore
aggiornaCalibrata(); // #92: senza calibrazione il riquadro dice che non c'e'
}
// #93: CONFERMA sotto la calibrata: voce scelta -> PUT calibrazione-attiva, «nessuna» -> DELETE calibration
$("confermaCal").onclick = function () {
var nome = $("sceltaCal").value;
if (nome) caricaNome(nome, "msgSceltaCal"); else scollega("msgSceltaCal");
};
// «Elimina» di una riga: conferma in pagina con un secondo clic entro 4 s (niente finestre del browser)
function annullaConferma() {
clearTimeout(C.confermaTimer);
@@ -927,25 +1264,20 @@ async function eliminaNome(nome, btn) {
// #91: il calcolo non salva mai; il servizio tiene il risultato e ne da' il calcolo_id per «Salva»
async function calcola() {
var daFile = $("origFile").checked;
var sorgente = $("sorgente").value;
if (!daFile && !sorgente) { messaggio("Scegliere una sorgente nella tab Acquisizione.", "errore"); return; }
var sorgente = $("sorgente").value; // #92: sempre la sorgente della tab Acquisizione, frame grezzo
if (!sorgente) { messaggio("Scegliere una sorgente nella tab Acquisizione.", "errore"); return; }
var lato = leggiNumero($("quadrato").value);
if (!(lato > 0)) { messaggio("Il lato dello scacco deve essere un numero maggiore di zero.", "errore"); return; }
var f = new FormData();
f.append("square_mm", String(lato)); // al servizio sempre con il punto
if (daFile) {
var file = $("file").files[0];
if (!file) { messaggio("Scegliere un file immagine.", "errore"); return; }
f.append("image", file);
} else {
f.append("source", sorgente);
}
var eraAttiva = !daFile && A.attiva; // #87: da sorgente l'anteprima si sospende e poi riprende
if (eraAttiva) fermaAnteprima();
f.append("source", sorgente);
var bloccata = A.bloccata; // #94: immagine bloccata = calcolo sulla copia del servizio, l'anteprima puo' continuare
if (bloccata) f.append("bloccata", "true");
else fermaAnteprima(); // #92: si ferma il ciclo; sotto si aspetta anche l'eventuale richiesta ancora in volo
messaggio("Calcolo in corso...", "");
["acquisisci", "salva"].forEach(function (i) { $(i).disabled = true; });
try {
while (!bloccata && A.inVolo) await pausa(50);
var r = await fetch("/camera/calibrate", { method: "POST", body: f });
if (!r.ok) { messaggio(await dettaglio(r), "errore"); return; }
var d = await r.json();
@@ -953,7 +1285,7 @@ async function calcola() {
tabella($("tabella"), d);
$("overlay").src = "data:image/png;base64," + d.overlay_png;
$("avviso").textContent = avvisi(d);
$("origineCalcolo").textContent = "Origine: " + descriviOrigine(d.origine);
$("origineCalcolo").textContent = "Origine: " + (d.bloccata ? "immagine bloccata — " : "") + descriviOrigine(d.origine); // #94
$("risultato").hidden = false;
annullaSovrascrivi();
dire("msgSalva", "");
@@ -963,7 +1295,6 @@ async function calcola() {
} finally {
$("acquisisci").disabled = false;
$("salva").disabled = false;
if (eraAttiva) avviaAnteprima();
}
}
@@ -1004,17 +1335,64 @@ async function salvaNome() {
}
}
// ---- #95: calibrazione manuale (mm/px a mano)
var M = { nome: null, timer: null }; // secondo clic su «Sovrascrivi» entro 4 s
function statoManuale() {
var s = $("sorgente").value;
$("salvaManuale").disabled = !s;
$("notaManuale").textContent = s ? "" : "Scegliere una sorgente nella tab Acquisizione: serve per registrare la risoluzione dell'immagine.";
}
function aggiornaManuale() {
var v = leggiNumero($("mmPx").value);
$("pxMmCalc").textContent = v > 0 ? "= " + fmt(1 / v, 4) + " px/mm" : "= - px/mm";
}
function annullaSovrascriviManuale() {
clearTimeout(M.timer);
M.timer = null;
M.nome = null;
$("salvaManuale").textContent = "Salva";
}
async function salvaManuale() {
var sorgente = $("sorgente").value;
if (!sorgente) { dire("msgManuale", "Scegliere una sorgente nella tab Acquisizione.", "errore"); return; }
var mm = leggiNumero($("mmPx").value);
if (!(mm >= 0.00001 && mm <= 100)) { dire("msgManuale", "mm/px deve essere un numero positivo (da 0,00001 a 100).", "errore"); return; }
var nome = $("nomeManuale").value.trim();
if (!nome) { dire("msgManuale", "Scrivere un nome per la calibrazione.", "errore"); return; }
if (nome.length > 60) { dire("msgManuale", "Il nome può avere al massimo 60 caratteri.", "errore"); return; }
var sovrascrivi = M.timer !== null && M.nome === nome.toLowerCase(); // secondo clic sullo stesso nome
annullaSovrascriviManuale();
$("salvaManuale").disabled = true;
try {
var r = await fetch("/camera/calibrazioni/manuale", {
method: "POST", headers: JSON_H,
body: JSON.stringify({ source: sorgente, nome: nome, mm_per_px: mm, sovrascrivi: sovrascrivi }) // al servizio sempre il numero, mai il testo con la virgola
});
if (r.status === 409) {
M.nome = nome.toLowerCase();
$("salvaManuale").textContent = "Sovrascrivi «" + nome + "»? Clic ancora";
M.timer = setTimeout(annullaSovrascriviManuale, 4000);
dire("msgManuale", await dettaglio(r), "errore");
return;
}
if (!r.ok) { dire("msgManuale", await dettaglio(r), "errore"); return; }
dire("msgManuale", "Calibrazione «" + nome + "» salvata: per usarla «Carica» o CONFERMA.", "ok");
caricaCalibrazioni();
} catch (e) {
dire("msgManuale", "Servizio non raggiungibile.", "errore");
} finally {
statoManuale();
}
}
$("mmPx").oninput = aggiornaManuale;
$("salvaManuale").onclick = salvaManuale;
$("nomeManuale").onkeydown = function (e) { if (e.key === "Enter") salvaManuale(); };
statoManuale();
$("acquisisci").onclick = calcola;
$("salva").onclick = salvaNome;
$("nomeCal").onkeydown = function (e) { if (e.key === "Enter") salvaNome(); };
// #87: il pulsante del file e' in italiano, l'input nativo resta nascosto
$("scegliFile").onclick = function () { $("file").click(); };
$("file").onchange = function () {
$("nomeFile").textContent = $("file").files[0] ? $("file").files[0].name : "nessuna immagine scelta";
if ($("file").files[0]) $("origFile").checked = true; // #91: scelto un file, l'origine diventa il file
};
$("sorgente").onchange = function () { cambioSorgente(true); };
$("sorgente").onchange = function () { dire("msgSorgente", ""); cambioSorgente(true); }; // #92
scegliTab(memoria("tmf.acq.tab") === "cal" ? "cal" : "acq", false); // #91: tab ricordata
caricaSorgenti();
</script>
+111 -16
View File
@@ -29,6 +29,10 @@ Calibrazione a scacchiera (#83, logica in scripts/calibrazione.py).
calibrated_at, attiva_su}], attiva: nome|null} (dalla piu' recente)
POST /camera/calibrazioni json {calcolo_id, nome, sovrascrivi=false} -> 201
metadati. 404 calcolo scaduto, 400 nome non valido, 409 nome gia' usato
POST /camera/calibrazioni/manuale json {source, nome, mm_per_px, sovrascrivi=false}
-> 201 metadati (#95: mm/px impostato a mano, metodo "manuale", nessuna
mappa; non la attiva). 400 mm/px fuori 0.00001..100 o nome non valido,
409 nome gia' usato; la risoluzione e' quella del frame tenuto o di un grab
DELETE /camera/calibrazioni?nome=X -> 204 (toglie anche la mappa e la scollega
da ogni sorgente) | 404
PUT /camera/calibrazione-attiva json {source, nome}: legge un frame dalla
@@ -84,10 +88,12 @@ from __future__ import annotations
import argparse
import logging
import math # #95: mm/px finito nella calibrazione manuale
import os
import re # #92: nome sicuro per le immagini caricate
import sys
import threading
import time # #92: scadenza della cache delle camere
import urllib.parse # #92: nome calibrazione nell'header X-Calibrazione
import uuid # #91 id dei calcoli in attesa di nome
from collections import OrderedDict
@@ -110,6 +116,7 @@ from calibrazione_pagina import PAGINA # noqa: E402
_TRIGGER = ("continuo",)
_MAX_CALCOLI = 8 # #91: calcoli tenuti in memoria in attesa di un nome
_CACHE_CAMERE_S = 60.0 # #92: durata della cache della ricerca camere
_log = logging.getLogger("camera_service") # #87: errori non bloccanti (settaggi)
# #87: i quattro parametri regolabili, nell'ordine di presentazione
_PARAMETRI = ("esposizione", "esposizione_auto", "guadagno", "guadagno_auto")
@@ -154,6 +161,7 @@ def create_app(folder: Path | str | None = None,
else _config_dir_default()) # #83
aperte: dict = {}
grezzi: dict = {} # #92: sorgente -> ultimo frame pieno GREZZO dell'anteprima (per anteprima/calibrata)
bloccati: dict = {} # #94: sorgente -> copia del frame grezzo pieno «bloccata» per il calcolo (stesso lock di grezzi)
cartella_immagini = archivio.cartella / "immagini" # #92: immagini caricate dalla pagina (creata al bisogno)
lock = threading.Lock() # protegge SOLO i dizionari, mai un'apertura
lock_sorgenti: dict = {} # nome sorgente -> Lock che serializza apertura e grab
@@ -169,8 +177,32 @@ def create_app(folder: Path | str | None = None,
with lock:
return lock_sorgenti.setdefault(nome, threading.Lock())
# #92: la ricerca camere di list_sources dura ~10 s: il risultato (gia' filtrato) resta 60 s in cache
cache_camere: dict = {"voci": None, "t": 0.0}
lock_ricerca = threading.Lock() # due richieste insieme non lanciano due ricerche
def _camere(aggiorna: bool = False) -> list:
with lock_ricerca:
fresca = cache_camere["voci"] is not None and time.monotonic() - cache_camere["t"] < _CACHE_CAMERE_S
if fresca and not aggiorna:
return cache_camere["voci"]
voci = list_sources(None, cameras=True, config=_config())
# camera:file (replay) non e' mai configurato in TMF -> fuori dall'elenco
cache_camere["voci"] = [{"name": v.name, "kind": v.kind, "label": v.label}
for v in voci if v.kind in ("camera", "virtual") and v.name != "camera:file"]
cache_camere["t"] = time.monotonic()
return cache_camere["voci"]
def _prima_ricerca() -> None:
try:
_camere()
except Exception: # noqa: BLE001 - e' solo un riscaldamento: la prima richiesta riprova
_log.exception("prima ricerca camere fallita")
@asynccontextmanager
async def _vita(_app):
# #92: la prima ricerca parte all'avvio, cosi' la prima pagina non aspetta
threading.Thread(target=_prima_ricerca, daemon=True, name="ricerca-camere").start()
yield
with lock: # alla chiusura rilascia le camere aperte
sorgenti = list(aperte.values())
@@ -184,12 +216,9 @@ def create_app(folder: Path | str | None = None,
app = FastAPI(title="TMF camera service", lifespan=_vita)
@app.get("/camera/sources")
def camera_sources():
voci = list_sources(None, cameras=True, config=_config())
# #92: camera:file (replay) non e' mai configurato in TMF -> fuori dall'elenco
elenco = [{"name": v.name, "kind": v.kind, "label": v.label}
for v in voci if v.kind in ("camera", "virtual") and v.name != "camera:file"]
# #92: in coda le immagini caricate, che sono sorgenti a pieno titolo
def camera_sources(aggiorna: bool = False): # #92: aggiorna=true forza la ricerca (camera appena collegata)
elenco = list(_camere(aggiorna))
# #92: in coda le immagini caricate, sempre fresche dalla cartella (costano nulla)
for v in list_sources(cartella_immagini, cameras=False):
elenco.append({"name": v.name, "kind": "image", "label": f"immagine — {v.name}"})
return {"sources": elenco}
@@ -200,6 +229,7 @@ def create_app(folder: Path | str | None = None,
with lock:
s = aperte.pop(nome, None)
grezzi.pop(nome, None)
bloccati.pop(nome, None) # #94: tolta l'immagine/chiusa la sorgente, anche la copia bloccata
if s is not None:
try:
s.close()
@@ -345,6 +375,7 @@ def create_app(folder: Path | str | None = None,
rows: int | None = Form(None),
image: UploadFile | None = File(None),
save: bool = Form(False), # #91: ignorato, il calcolo non salva mai (si salva con nome)
bloccata: bool = Form(False), # #94: true = calcola sulla copia bloccata, senza grab
):
contenuto = await image.read() if image is not None else b""
if contenuto:
@@ -352,6 +383,13 @@ def create_app(folder: Path | str | None = None,
if frame is None:
raise HTTPException(422, "l'immagine caricata non è leggibile")
origine = "file:" + (Path((image.filename or "").replace("\\", "/")).name or "immagine")
elif bloccata: # #94: copia bloccata, nessun grab e nessun lock camera
nome = _sorgente(source)
with lock:
frame = bloccati.get(nome)
if frame is None:
raise HTTPException(409, "nessuna immagine bloccata su questa sorgente")
origine = nome
else:
nome = _sorgente(source)
frame = await run_in_threadpool(_grab, nome, 5000) # come /camera/grab (404/503/504)
@@ -374,7 +412,8 @@ def create_app(folder: Path | str | None = None,
calcoli[calcolo_id] = (corpo, mappa)
while len(calcoli) > _MAX_CALCOLI:
calcoli.popitem(last=False)
return {**corpo, "calcolo_id": calcolo_id, "overlay_png": risultato["overlay_png"]}
return {**corpo, "calcolo_id": calcolo_id, "overlay_png": risultato["overlay_png"],
"bloccata": bool(bloccata and not contenuto)} # #94: solo nella risposta (non nel salvato): true se il frame e' la copia bloccata
# ---- #91: archivio unico di calibrazioni con nome
@@ -406,6 +445,35 @@ def create_app(folder: Path | str | None = None,
except calibrazione.NomeGiaUsato as e:
raise HTTPException(409, str(e))
@app.post("/camera/calibrazioni/manuale", status_code=201)
def calibrazioni_manuale(corpo: dict = Body(default=None)):
"""#95: calibrazione con mm/px impostato a mano (nessuna scacchiera, nessuna mappa); NON la attiva."""
corpo = corpo if isinstance(corpo, dict) else {}
sorgente = _sorgente(corpo.get("source"))
mm_px = corpo.get("mm_per_px")
if (isinstance(mm_px, bool) or not isinstance(mm_px, (int, float))
or not math.isfinite(mm_px) or not 0.00001 <= mm_px <= 100):
raise HTTPException(400, "mm/px deve essere un numero positivo")
sovrascrivi = corpo.get("sovrascrivi", False)
if not isinstance(sovrascrivi, bool):
raise HTTPException(400, "«sovrascrivi» deve essere true o false")
try:
nome = calibrazione.pulisci_nome(corpo.get("nome"))
except calibrazione.NomeNonValido as e:
raise HTTPException(400, str(e))
with lock:
frame = grezzi.get(sorgente)
if frame is None: # nessun frame tenuto: un grab come /camera/grab (404/503/504)
frame = _grab(sorgente, 5000)
alt, larg = frame.shape[:2] # serve al controllo W×H di PUT /camera/calibrazione-attiva
dati = {"px_per_mm": 1.0 / mm_px, "mm_per_px": float(mm_px), "metodo": "manuale",
"image_width": larg, "image_height": alt, "origine": sorgente,
"calibrated_at": calibrazione.ora_iso(), "rms_px": None, "square_mm": None}
try:
return archivio.salva_nome(nome, dati, None, sovrascrivi)
except calibrazione.NomeGiaUsato as e:
raise HTTPException(409, str(e))
@app.delete("/camera/calibrazioni", status_code=204)
def calibrazioni_elimina(nome: str = ""):
if not archivio.elimina_nome(nome):
@@ -508,9 +576,28 @@ def create_app(folder: Path | str | None = None,
"X-Nitidezza": f"{nitidezza:.1f}", "X-Saturati-Pct": f"{saturati:.2f}",
"X-Media": f"{media:.1f}"})
@app.post("/camera/blocca")
def camera_blocca(corpo: dict = Body(default=None)):
"""#94: copia l'ultimo frame grezzo della sorgente; /camera/calibrate con bloccata=true calcola su quella."""
corpo = corpo if isinstance(corpo, dict) else {}
nome = _sorgente(corpo.get("source"))
with lock:
frame = grezzi.get(nome)
if frame is None:
raise HTTPException(404, "nessun frame: avviare l'anteprima o scattare")
bloccati[nome] = frame.copy()
alt, larg = frame.shape[:2]
return {"source": nome, "larghezza": larg, "altezza": alt}
@app.delete("/camera/blocca", status_code=204)
def camera_sblocca(source: str = ""):
with lock: # #94: idempotente, 204 anche se non c'era nulla
bloccati.pop(source.strip(), None)
return Response(status_code=204)
@app.post("/camera/anteprima/calibrata")
def camera_anteprima_calibrata(corpo: dict = Body(default=None)):
"""#92: l'ultimo frame grezzo dell'anteprima, raddrizzato; NON legge un frame nuovo."""
"""#92: l'ultimo frame grezzo dell'anteprima, raddrizzato; NON legge un frame nuovo. #95: senza mappa esce grezzo (X-Raddrizzato: no)."""
corpo = corpo if isinstance(corpo, dict) else {}
nome = _sorgente(corpo.get("source"))
lato = corpo.get("lato_max", 1280)
@@ -524,14 +611,22 @@ def create_app(folder: Path | str | None = None,
if attiva is None:
raise HTTPException(409, "nessuna calibrazione attiva su questa sorgente")
mappa = archivio.leggi_mappa(nome)
if mappa is None: # planare: ha solo scala, nessuna mappa da applicare
raise HTTPException(409, f"la calibrazione «{attiva}» non ha una mappa di correzione (planare)")
corretto = calibrazione.raddrizza(frame, mappa)
if corretto is None: # risoluzione diversa da quella calibrata
ch, cw = mappa.shape[:2]
if mappa is None: # #95: senza mappa (manuale o planare) solo scala: frame grezzo, nessuna correzione
dati = archivio.leggi(nome) or {}
cw, ch = dati.get("image_width"), dati.get("image_height")
alt, larg = frame.shape[:2]
raise HTTPException(409, f"la calibrazione «{attiva}» è per immagini {cw}×{ch}, "
f"il frame è {larg}×{alt}")
if isinstance(cw, int) and isinstance(ch, int) and (cw, ch) != (larg, alt):
raise HTTPException(409, f"la calibrazione «{attiva}» è per immagini {cw}×{ch}, "
f"il frame è {larg}×{alt}")
corretto, raddrizzato = frame, "no"
else:
corretto = calibrazione.raddrizza(frame, mappa)
if corretto is None: # risoluzione diversa da quella calibrata
ch, cw = mappa.shape[:2]
alt, larg = frame.shape[:2]
raise HTTPException(409, f"la calibrazione «{attiva}» è per immagini {cw}×{ch}, "
f"il frame è {larg}×{alt}")
raddrizzato = "si"
alt, larg = corretto.shape[:2]
if corretto.dtype != np.uint8: # il JPEG vuole 8 bit
corretto = _grigio8(corretto)
@@ -543,7 +638,7 @@ def create_app(folder: Path | str | None = None,
if not ok:
raise HTTPException(500, "codifica JPEG fallita")
return Response(buf.tobytes(), media_type="image/jpeg", headers={
"X-Raddrizzato": "si", "X-Larghezza": str(larg), "X-Altezza": str(alt),
"X-Raddrizzato": raddrizzato, "X-Larghezza": str(larg), "X-Altezza": str(alt),
"X-Calibrazione": urllib.parse.quote(attiva, safe="")})
@app.get("/camera/parametri")
+156
View File
@@ -0,0 +1,156 @@
"""Test di #94: «Blocca immagine» (scripts/camera_service.py) e riquadro vivo nella tab Calibrazione (PAGINA).
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
Sorgente finta che conta i grab: niente camera vera, niente ProgramData.
"""
import re
import numpy as np
import pytest
from test_calibrazione import _png, cartella, client, una_scacchiera # fixture e helper della calibrazione
from test_immagini_affiancate import _anteprima, _carica, _modulo
from test_pagina_acquisizione import PAGINA
__all__ = ["cartella", "client"] # le fixture importate restano usate
FINTA = "finta"
CAMPI = {"cols": "9", "rows": "6", "square_mm": "2"}
@pytest.fixture
def finta(client, monkeypatch):
"""Sorgente «finta»: il primo grab da' la scacchiera, i successivi un frame bianco (non calibrabile)."""
letture = []
scacchiera = una_scacchiera()
bianco = np.full(scacchiera.shape, 255, np.uint8)
class Finta:
def grab(self, timeout=1000):
letture.append(1)
return scacchiera.copy() if len(letture) == 1 else bianco.copy()
def close(self):
pass
monkeypatch.setitem(_modulo(client), "open_source", lambda *a, **k: Finta())
client.letture = letture
return client
def _blocca(c, nome=FINTA):
return c.post("/camera/blocca", json={"source": nome})
def _calcola(c, **extra):
return c.post("/camera/calibrate", data={"source": FINTA, **CAMPI, **extra})
# ---- servizio
def test_blocca_senza_frame_404(finta):
r = _blocca(finta)
assert r.status_code == 404 and r.json()["detail"] == "nessun frame: avviare l'anteprima o scattare"
def test_blocca_senza_source_400(finta):
assert finta.post("/camera/blocca", json={}).status_code == 400
assert finta.post("/camera/blocca").status_code == 400
def test_anteprima_poi_blocca_200_con_misure(finta):
assert _anteprima(finta, FINTA).status_code == 200
r = _blocca(finta)
alt, larg = una_scacchiera().shape[:2]
assert r.status_code == 200 and r.json() == {"source": FINTA, "larghezza": larg, "altezza": alt}
def test_calibrate_bloccata_usa_la_copia_senza_grab(finta):
assert _anteprima(finta, FINTA).status_code == 200 # frame 1: la scacchiera
assert _blocca(finta).status_code == 200
assert _anteprima(finta, FINTA).status_code == 200 # frame 2: bianco, il grezzo cambia ma la copia no
prima = len(finta.letture)
r = _calcola(finta, bloccata="true")
assert r.status_code == 200, r.text
assert r.json()["bloccata"] is True and r.json()["origine"] == FINTA
assert len(finta.letture) == prima # nessun grab per il calcolo
# senza il blocco il calcolo legge un frame nuovo (bianco): non calibrabile
assert _calcola(finta).status_code == 422
assert len(finta.letture) == prima + 1
def test_calibrate_normale_dice_bloccata_false(client):
r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI})
assert r.status_code == 200, r.text
assert r.json()["bloccata"] is False
def test_calibrate_bloccata_senza_copia_409(finta):
assert _anteprima(finta, FINTA).status_code == 200 # il grezzo c'e', ma non e' bloccato
r = _calcola(finta, bloccata="true")
assert r.status_code == 409 and r.json()["detail"] == "nessuna immagine bloccata su questa sorgente"
def test_sblocca_204_idempotente_e_toglie_la_copia(finta):
assert finta.delete("/camera/blocca", params={"source": FINTA}).status_code == 204 # niente da togliere
assert _anteprima(finta, FINTA).status_code == 200
assert _blocca(finta).status_code == 200
assert finta.delete("/camera/blocca", params={"source": FINTA}).status_code == 204
assert _calcola(finta, bloccata="true").status_code == 409
assert finta.delete("/camera/blocca", params={"source": FINTA}).status_code == 204
def test_rimuovere_l_immagine_toglie_la_copia_bloccata(client):
assert _carica(client, "foto.png", una_scacchiera()).status_code == 201
assert _anteprima(client, "foto.png").status_code == 200
assert _blocca(client, "foto.png").status_code == 200
assert client.delete("/camera/immagini", params={"nome": "foto.png"}).status_code == 204
assert _carica(client, "foto.png", una_scacchiera()).status_code == 201
r = client.post("/camera/calibrate", data={"source": "foto.png", "bloccata": "true", **CAMPI})
assert r.status_code == 409
# ---- pagina (contenuto statico)
def test_pagina_riquadro_e_casella_nella_tab_calibrazione():
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
assert {"immagineCalTab", "vuotoCalTab", "scattaCal", "bloccaImg", "msgBlocco"} <= ids
assert "Nessuna immagine: «Scatta» o avviare l'anteprima nella tab Acquisizione" in PAGINA
assert "Blocca immagine" in PAGINA
tab = PAGINA.split('id="pannelloCal"')[1].split('id="sezioneSalvate"')[0]
assert "immagineCalTab" in tab and "bloccaImg" in tab # nella sezione Calibrazione, non altrove
def test_pagina_scegli_tab_non_ferma_piu_il_ciclo():
assert "A.riprendi" not in PAGINA
scegli = PAGINA.split("function scegliTab")[1].split("$(\"tabAcq\").onclick")[0]
assert "fermaAnteprima" not in scegli and "avviaAnteprima" not in scegli
def test_pagina_calcola_manda_bloccata_e_non_ferma_il_ciclo():
calcola = PAGINA.split("async function calcola")[1].split("async function salvaNome")[0]
assert 'f.append("bloccata", "true")' in calcola
assert "if (bloccata)" in calcola and "else fermaAnteprima();" in calcola
assert "immagine bloccata" in calcola
assert "l'anteprima resta sospesa" not in PAGINA and "si può bloccare l'immagine" in PAGINA
def test_pagina_blocco_rotte_e_cambio_sorgente():
assert '"/camera/blocca"' in PAGINA and '"/camera/blocca?source="' in PAGINA
assert "method: \"DELETE\"" in PAGINA
cambio = PAGINA.split("function cambioSorgente")[1].split("// ---- Anteprima")[0]
assert "rilasciaBlocco()" in cambio
assert cambio.index("rilasciaBlocco()") < cambio.index("svuotaRiquadri()")
def test_pagina_blocco_attende_l_anteprima_in_volo():
# #94: il frame bloccato e' quello mostrato: flag A.bloccando, attesa di A.inVolo (20 ms, max 3 s), poi POST, flag tolto in finally
blocco = PAGINA.split('$("bloccaImg").onchange')[1].split('$("avvia").onclick')[0]
assert "A.bloccando++" in blocco and "A.bloccando--" in blocco and "finally" in blocco
assert "k < 150" in blocco and "pausa(20)" in blocco
assert blocco.index("A.inVolo") < blocco.index('fetch("/camera/blocca"')
ciclo = PAGINA.split("async function ciclo")[1].split("function avviaAnteprima")[0]
assert "while (A.bloccando)" in ciclo and ciclo.index("A.bloccando") < ciclo.index("unaAnteprima(1280)")
scatta = PAGINA.split("async function scatta")[1].split("// #94: «Blocca immagine»")[0]
assert "while (A.bloccando)" in scatta and scatta.index("A.bloccando") < scatta.index("unaAnteprima(4096)")
+2 -2
View File
@@ -169,7 +169,7 @@ def test_c2_immagine_caricata_senza_salvare(client):
files={"image": ("s.png", _png(una_scacchiera()), "image/png")})
assert r.status_code == 200, r.text
corpo = r.json()
assert CHIAVI_PLANARE | {"overlay_png", "calcolo_id"} == set(corpo) # #91: niente "saved", niente "source"
assert CHIAVI_PLANARE | {"overlay_png", "calcolo_id", "bloccata"} == set(corpo) # #94: + bloccata; #91: niente "saved", niente "source"
assert corpo["metodo"] == "planare" and corpo["distorsione"] is False # #84: con cols/rows nulla cambia
assert corpo["origine"] == "file:s.png"
assert corpo["px_per_mm"] == pytest.approx(20.0, rel=0.005)
@@ -337,7 +337,7 @@ def test_c2_senza_cols_rows_e_densa(client_denso):
r = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"})
assert r.status_code == 200, r.text
corpo = r.json()
assert CHIAVI_DENSA | {"overlay_png", "calcolo_id"} == set(corpo)
assert CHIAVI_DENSA | {"overlay_png", "calcolo_id", "bloccata"} == set(corpo)
assert corpo["metodo"] == "densa" and corpo["distorsione"] is True
assert (corpo["cols"], corpo["rows"]) == (15, 11)
assert corpo["px_per_mm"] == pytest.approx(1.0 / corpo["mm_per_px"])
+148
View File
@@ -0,0 +1,148 @@
"""Test di #95: calibrazione manuale (mm/px impostato a mano) in scripts/camera_service.py e nella PAGINA.
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
Sorgenti = immagini caricate in tmp_path: niente camera vera, niente ProgramData.
"""
import re
import numpy as np
import pytest
from test_calibrazione import _png, cartella, client # fixture della calibrazione
from test_immagini_affiancate import _anteprima, _calibrata, _carica
from test_pagina_acquisizione import PAGINA
__all__ = ["cartella", "client"] # le fixture importate restano usate
SRC = "banco.png" # 64x48
ALTRA = "grande.png" # 80x60, risoluzione diversa
@pytest.fixture
def sorgenti(client):
assert _carica(client, SRC, np.full((48, 64), 120, np.uint8)).status_code == 201
assert _carica(client, ALTRA, np.full((60, 80), 120, np.uint8)).status_code == 201
return client
def _manuale(c, nome="Manuale", mm=0.05, source=SRC, **extra):
return c.post("/camera/calibrazioni/manuale",
json={"source": source, "nome": nome, "mm_per_px": mm, **extra})
# ---- servizio
def test_manuale_201_con_scala_e_risoluzione_del_frame(sorgenti):
r = _manuale(sorgenti, mm=0.05)
assert r.status_code == 201, r.text
m = r.json()
assert m["nome"] == "Manuale" and m["metodo"] == "manuale"
assert m["px_per_mm"] == pytest.approx(20.0) and m["mm_per_px"] == pytest.approx(0.05)
assert (m["image_width"], m["image_height"]) == (64, 48)
assert m["origine"] == SRC and m["square_mm"] is None and m["attiva_su"] == [] # non la attiva
voci = sorgenti.get("/camera/calibrazioni").json()["calibrazioni"]
assert [v["nome"] for v in voci] == ["Manuale"]
def test_manuale_usa_il_frame_tenuto_se_c_e(sorgenti):
assert _anteprima(sorgenti, SRC).status_code == 200 # tiene il grezzo 64x48
r = _manuale(sorgenti)
assert r.status_code == 201 and (r.json()["image_width"], r.json()["image_height"]) == (64, 48)
@pytest.mark.parametrize("valore", [0, -1, 0.000001, 100.5, "abc", None, True])
def test_manuale_mm_non_valido_400(sorgenti, valore):
r = _manuale(sorgenti, mm=valore)
assert r.status_code == 400
assert "mm/px deve essere un numero positivo" in r.json()["detail"]
def test_manuale_senza_source_o_nome_400_e_sorgente_ignota_404(sorgenti):
assert sorgenti.post("/camera/calibrazioni/manuale", json={"nome": "x", "mm_per_px": 0.1}).status_code == 400
assert _manuale(sorgenti, nome=" ").status_code == 400
assert _manuale(sorgenti, source="nonesiste.png").status_code == 404
def test_manuale_409_nome_doppio_e_sovrascrivi(sorgenti):
assert _manuale(sorgenti, mm=0.05).status_code == 201
r = _manuale(sorgenti, mm=0.1)
assert r.status_code == 409 and "esiste già una calibrazione" in r.json()["detail"]
r = _manuale(sorgenti, mm=0.1, sovrascrivi=True)
assert r.status_code == 201 and r.json()["px_per_mm"] == pytest.approx(10.0)
assert _manuale(sorgenti, mm=0.1, sovrascrivi="si").status_code == 400
def test_attiva_manuale_ok_su_stessa_risoluzione_e_legge_px_per_mm(sorgenti):
assert _manuale(sorgenti, mm=0.05).status_code == 201
r = sorgenti.put("/camera/calibrazione-attiva", json={"source": SRC, "nome": "Manuale"})
assert r.status_code == 200, r.text
g = sorgenti.get("/camera/calibration", params={"source": SRC})
assert g.status_code == 200 and g.json()["px_per_mm"] == pytest.approx(20.0)
assert g.json()["nome"] == "Manuale" and g.json()["metodo"] == "manuale"
def test_attiva_manuale_409_su_risoluzione_diversa(sorgenti):
assert _manuale(sorgenti).status_code == 201
r = sorgenti.put("/camera/calibrazione-attiva", json={"source": ALTRA, "nome": "Manuale"})
assert r.status_code == 409 and "64×48" in r.json()["detail"]
def test_anteprima_calibrata_con_manuale_200_grezza(sorgenti):
assert _manuale(sorgenti).status_code == 201
assert sorgenti.put("/camera/calibrazione-attiva", json={"source": SRC, "nome": "Manuale"}).status_code == 200
assert _anteprima(sorgenti, SRC).status_code == 200
r = _calibrata(sorgenti, SRC)
assert r.status_code == 200, r.text
assert r.headers["X-Raddrizzato"] == "no" and r.headers["X-Calibrazione"] == "Manuale"
assert (r.headers["X-Larghezza"], r.headers["X-Altezza"]) == ("64", "48")
assert r.headers["content-type"] == "image/jpeg"
def test_anteprima_calibrata_manuale_409_se_il_frame_ha_altra_risoluzione(sorgenti):
assert _manuale(sorgenti).status_code == 201
assert sorgenti.put("/camera/calibrazione-attiva", json={"source": SRC, "nome": "Manuale"}).status_code == 200
assert _anteprima(sorgenti, ALTRA).status_code == 200
# stessa sorgente attiva, ma il frame tenuto cambia (immagine sostituita con altra misura)
assert _carica(sorgenti, SRC, np.full((60, 80), 9, np.uint8)).status_code == 201
assert _anteprima(sorgenti, SRC).status_code == 200
r = _calibrata(sorgenti, SRC)
assert r.status_code == 409 and "64×48" in r.json()["detail"]
# ---- pagina
def test_pagina_scheda_manuale_id_e_testi():
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
assert {"mmPx", "pxMmCalc", "nomeManuale", "salvaManuale", "msgManuale"} <= ids
tab = PAGINA.split('id="pannelloCal"')[1]
assert tab.index('id="sezioneCalibrazione"') < tab.index('id="sezioneManuale"') < tab.index('id="sezioneSalvate"')
assert "Calibrazione manuale" in PAGINA and "mm per pixel" in PAGINA
assert "salvata: per usarla «Carica» o CONFERMA" in PAGINA
assert "Sovrascrivi «" in PAGINA.split("async function salvaManuale")[1] # stessa conferma del Salva scacchiera
assert "/camera/calibrazioni/manuale" in PAGINA and "alert(" not in PAGINA and "confirm(" not in PAGINA
def test_pagina_manuale_senza_sorgente_pulsante_spento_con_nota():
stato = PAGINA.split("function statoManuale")[1].split("function aggiornaManuale")[0]
assert 'salvaManuale").disabled = !s' in stato and "Scegliere una sorgente" in stato
assert "statoManuale()" in PAGINA.split("async function aggiornaStato")[1].split("async function caricaCalibrazioni")[0]
def test_pagina_px_mm_calcolato_a_ogni_tasto():
assert '$("mmPx").oninput = aggiornaManuale' in PAGINA
assert '"= " + fmt(1 / v, 4) + " px/mm"' in PAGINA
def test_pagina_metodo_manuale_e_campi_null():
assert 'c.metodo === "densa" || c.metodo === "manuale" ? c.metodo : "planare"' in PAGINA
righe = PAGINA.split("function righe")[1].split("function tabella")[0]
assert 'd.metodo === "manuale"' in righe and "Lato dello scacco" in righe # la manuale ha il suo elenco, le altre il solito
assert 'v === null || v === undefined' in PAGINA.split("function fmt")[1].split("function fmtCasella")[0]
stato = PAGINA.split("async function aggiornaStato")[1].split("async function caricaCalibrazioni")[0]
assert "Number(d.square_mm) > 0" in stato
def test_pagina_calibrata_senza_raddrizzamento_dice_solo_scala():
assert "solo scala, nessuna correzione ottica" in PAGINA
assert 'r.headers.get("X-Raddrizzato") === "no"' in PAGINA
+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")
def test_sources_camere_in_cache_e_aggiorna(client):
# #92: la ricerca camere (~10 s) si fa una volta e resta 60 s; aggiorna=true la rilancia; le immagini sono sempre fresche
g = _modulo(client)
originale = g["list_sources"]
chiamate = []
def finto(cartella, cameras=True, **kw):
if cameras:
chiamate.append(1)
return originale(cartella, cameras=cameras, **kw)
g["list_sources"] = finto
try:
client.get("/camera/sources") # se la ricerca d'avvio e' ancora in corso aspetta il suo lock
base = len(chiamate)
_carica(client, "nuova.png", una_scacchiera())
voci = client.get("/camera/sources").json()["sources"]
client.get("/camera/sources")
assert len(chiamate) == base # seconda e terza chiamata: nessuna nuova ricerca
assert voci[-1]["name"] == "nuova.png" # ma l'immagine appena caricata c'e'
client.get("/camera/sources", params={"aggiorna": "true"})
assert len(chiamate) == base + 1 # aggiorna=true la rilancia
client.get("/camera/sources")
assert len(chiamate) == base + 1 # e il risultato nuovo torna in cache
finally:
g["list_sources"] = originale
def test_sources_senza_immagini_non_crea_la_cartella(client):
voci = client.get("/camera/sources").json()["sources"]
assert all(v["kind"] != "image" for v in voci)
@@ -254,10 +282,11 @@ def test_calibrata_risoluzione_diversa_409(client):
assert r.json()["detail"] == "la calibrazione «Banco» è per immagini 640×480, il frame è 320×240"
def test_calibrata_planare_senza_mappa_409(client):
def test_calibrata_planare_senza_mappa_200_grezza(client):
# #95: senza mappa (planare) non e' piu' 409: esce il frame grezzo, X-Raddrizzato: no
_carica(client, PIANA, una_scacchiera())
_calibra_e_attiva(client, PIANA, "Piana", cols="9", rows="6")
assert _anteprima(client, PIANA).status_code == 200
r = _calibrata(client, PIANA)
assert r.status_code == 409
assert r.json()["detail"] == "la calibrazione «Piana» non ha una mappa di correzione (planare)"
assert r.status_code == 200
assert r.headers["X-Raddrizzato"] == "no" and unquote(r.headers["X-Calibrazione"]) == "Piana"
+5 -5
View File
@@ -29,10 +29,10 @@ def test_offline_e_senza_finestre_del_browser():
def test_id_principali():
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
attesi = {"sorgente", "avvia", "ferma", "scatta", "grezzo", "mirinoCasella", "immagine",
attesi = {"sorgente", "avvia", "ferma", "scatta", "mirinoCasella", "immagine",
"nitidezza", "nitidezzaMax", "saturati", "media", "dimensioni",
"parametri", "salvaParametri", "quadrato", "acquisisci", "scegliFile", "file",
"salva", "stato", "statoSorgente"} # #91: carica/elimina sono ora per riga, nell'elenco
"parametri", "salvaParametri", "quadrato", "acquisisci", "caricaImmagine", "fileImmagine",
"mostraCal", "salva", "stato", "statoSorgente"} # #91: carica/elimina sono ora per riga; #92: file e calibrata nell'Acquisizione
assert attesi <= ids, attesi - ids
# ogni id usato dal JS con $("...") esiste nel markup
usati = set(re.findall(r'\$\("([^"]+)"\)', PAGINA))
@@ -41,8 +41,8 @@ def test_id_principali():
def test_sezioni_e_testi_italiani():
for testo in ("Sorgente", "Anteprima", "Parametri camera", "Calibrazione",
"Salva come predefiniti", "Scegli immagine...", "Questa sorgente non ha parametri regolabili",
"Immagine grezza (senza correzione)"):
"Salva come predefiniti", "Carica immagine da file...", "Questa sorgente non ha parametri regolabili",
"Attiva calibrazione"): # #92 #93
assert testo in PAGINA, testo
assert "it-IT" in PAGINA # numeri con la virgola
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 re
@@ -20,37 +20,58 @@ def test_due_tab_con_ruolo_e_selezione():
assert '"tmf.acq.tab"' in PAGINA # tab scelta ricordata (via memoria(), protetta)
def test_anteprima_fuori_dai_pannelli():
# l'anteprima sta nella colonna sinistra, prima dei due pannelli: visibile in entrambe le tab
i_ant = PAGINA.index('id="sezioneAnteprima"')
def test_sorgente_in_cima_poi_riquadri():
# #92: Sorgente, anteprima e parametri stanno nel pannello Acquisizione; Sorgente prima dei riquadri
i_acq = PAGINA.index('id="pannelloAcq"')
i_cal = PAGINA.index('id="pannelloCal"')
assert i_ant < i_acq < i_cal
# Sorgente e Parametri nel pannello Acquisizione, la calibrazione in quello Calibrazione
assert i_acq < PAGINA.index('id="sezioneSorgente"') < PAGINA.index('id="sezioneParametri"') < i_cal
i_sorg = PAGINA.index('id="sezioneSorgente"')
i_ant = PAGINA.index('id="sezioneAnteprima"')
i_riq = PAGINA.index('id="riquadri"')
assert i_acq < i_sorg < i_ant < i_riq < PAGINA.index('id="sezioneParametri"') < i_cal
assert PAGINA.index('id="sezioneCalibrazione"') > i_cal
# barra: casella calibrata e mirino; la vecchia casella «Immagine grezza» non c'e' piu'
assert 'id="mostraCal"' in PAGINA and "Attiva calibrazione" in PAGINA
assert 'id="grezzo"' not in PAGINA and "Immagine grezza (senza correzione)" not in PAGINA
# riquadri: «Immagine» sempre visibile, «Calibrata» nascosta finche' la casella e' spenta
assert re.search(r'<div class="riquadro" id="riquadroCal" hidden>', PAGINA)
assert "grezzo: true" in PAGINA
def test_id_del_pannello_calibrazione():
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
attesi = {"calSorgente", "origSorgente", "origFile", "scegliFile", "file", "quadrato", "acquisisci",
"nomeCal", "salva", "msgSalva", "calibrazioni", "msgElenco", "stato", "risultato", "overlay"}
attesi = {"calSorgente", "quadrato", "acquisisci", "nomeCal", "salva", "msgSalva", "calibrazioni",
"msgElenco", "stato", "risultato", "overlay",
"caricaImmagine", "fileImmagine", "rimuoviImmagine", "riquadroCal", "immagineCal", "mirinoCal"}
assert attesi <= ids, attesi - ids
usati = set(re.findall(r'\$\("([^"]+)"\)', PAGINA))
assert usati <= ids, usati - ids
assert len(re.findall(r'id="([^"]+)"', PAGINA)) == len(ids) # nessun id doppio
def test_calibrazione_senza_file_ne_radio():
# #92: l'immagine da file e' una sorgente di acquisizione, non un'opzione della calibrazione
for sparito in ("origFile", "origSorgente", "scegliFile", "nomeFile", 'type="radio"', "Immagine da file", "Sorgente selezionata"):
assert sparito not in PAGINA, sparito
# l'unico input file e' nella sezione Sorgente, in cima alla tab Acquisizione
assert PAGINA.count('type="file"') == 1
assert PAGINA.index('type="file"') < PAGINA.index('id="pannelloCal"')
assert "immagine grezza dalla tab Acquisizione" in PAGINA
# il calcolo manda solo la sorgente (e il lato), mai il file
assert 'f.append("source", sorgente)' in PAGINA and 'f.append("image"' not in PAGINA.split("async function calcola")[1].split("async function salvaNome")[0]
def test_testi_del_pannello():
for testo in ("Sorgente selezionata", "Immagine da file", "Scegli immagine...", "Calcola",
"Calibrazioni salvate", "si cambia nella tab Acquisizione", "risoluzione diversa",
"Carica", "Elimina", "Scollega", "Sovrascrivi «", "Clic ancora", "Nome"):
for testo in ("Camera o immagine", "Carica immagine da file...", "Rimuovi immagine", "Calcola",
"Calibrazioni salvate", "risoluzione diversa", "Calibrata",
"Carica", "Elimina", "Scollega", "Sovrascrivi «", "Clic ancora", "Nome",
"calibrata: ", "non calibrata"):
assert testo in PAGINA, testo
def test_rotte_del_contratto():
for rotta in ("/camera/calibrazioni", "/camera/calibrazione-attiva", "/camera/calibrate",
"/camera/calibration?source="):
"/camera/calibration?source=", "/camera/immagini", "/camera/immagini?nome=",
"/camera/anteprima/calibrata"): # #92
assert '"' + rotta in PAGINA, rotta
for parola in ("calcolo_id", "sovrascrivi", "method: \"PUT\"", "method: \"DELETE\"", "X-Larghezza", "X-Altezza"):
assert parola in PAGINA, parola
@@ -65,7 +86,7 @@ def test_nessuna_finestra_del_browser_e_offline():
def test_acquisizione_di_87_90_conservata():
# anteprima, indicatori, mirino, grezzo, parametri camera e ciclo a 100 ms restano
for parola in ("Parametri camera", "Immagine grezza (senza correzione)", "Mirino centrale",
for parola in ("Parametri camera", "Mirino centrale",
"Math.max(100 - (Date.now() - t0), 0)", "/camera/anteprima", "/camera/parametri"):
assert parola in PAGINA, parola
@@ -75,3 +96,48 @@ def test_pagina_stretta():
# #91: elenco a schede, niente tabella larga ne' scorrimento orizzontale
assert 'id="calibrazioni" class="elenco"' in PAGINA and "overflow-x" not in PAGINA
assert "Lettura dei parametri..." in PAGINA
def test_ciclo_non_sospeso_nella_tab_calibrazione():
# #94: il ciclo non si ferma piu' entrando in Calibrazione (prima #92: A.riprendi); Calcola senza blocco aspetta le richieste in volo
assert "A.riprendi" not in PAGINA
calcola = PAGINA.split("async function calcola")[1].split("async function salvaNome")[0]
assert "fermaAnteprima();" in calcola and "A.inVolo) await pausa(50)" in calcola
def test_calibrata_dopo_anteprima_e_dopo_carica():
# #92: la calibrata segue ogni anteprima grezza riuscita e si aggiorna dopo Carica/Scollega; 404/409 al posto dell'immagine
assert "await aggiornaCalibrata(lato);" in PAGINA
assert PAGINA.count("aggiornaCalibrata();") >= 3
assert "messaggioCalibrata(await dettaglio(r))" in PAGINA
assert 'decodeURIComponent(r.headers.get("X-Calibrazione")' in PAGINA
def test_elenco_sorgenti_senza_attese_e_calibrata_solo_se_serve():
# #92: la voce dell'immagine caricata si aggiunge in pagina (niente rilettura dell'elenco); «Aggiorna» forza la ricerca camere
assert "aggiungiSorgente(j);" in PAGINA and "caricaSorgenti(j.source)" not in PAGINA
assert 'id="aggiornaSorgenti"' in PAGINA and ">Aggiorna</button>" in PAGINA
assert '"?aggiorna=true"' in PAGINA
# nessuna calibrazione attiva: il riquadro lo dice senza chiamare il servizio; dopo un 409 non si richiede piu'
assert "if (A.calAttiva === false)" in PAGINA
assert "nessuna calibrazione attiva su questa sorgente" in PAGINA
assert "if (r.status === 409) A.bloccoCal = true;" in PAGINA and "if (A.bloccoCal) return;" in PAGINA
assert PAGINA.count("A.bloccoCal = false;") >= 3 # cambio sorgente, Carica, Scollega
def test_attiva_calibrazione_con_scelta_e_conferma():
# #93: sotto la calibrata una combo e CONFERMA; la casella cambia nome
for i in ('id="sceltaCal"', 'id="confermaCal"', 'id="msgSceltaCal"'):
assert i in PAGINA, i
assert "Attiva calibrazione" in PAGINA and ">CONFERMA</button>" in PAGINA
assert "Mostra immagine calibrata" not in PAGINA
assert PAGINA.index('id="riquadroCal"') < PAGINA.index('id="sceltaCal"') < PAGINA.index('id="confermaCal"') < PAGINA.index('id="msgSceltaCal"')
# il gestore di CONFERMA usa PUT calibrazione-attiva (via caricaNome) o DELETE calibration (via scollega), messaggio sotto la calibrata
gestore = PAGINA[PAGINA.index('$("confermaCal").onclick'):]
gestore = gestore[:gestore.index("};")]
assert 'caricaNome(nome, "msgSceltaCal")' in gestore and 'scollega("msgSceltaCal")' in gestore
assert 'method: "PUT"' in PAGINA and "/camera/calibrazione-attiva" in PAGINA
assert 'method: "DELETE"' in PAGINA and '"/camera/calibration?source="' in PAGINA
# voce vuota «nessuna», suffisso di risoluzione, combo ridisegnata con l'elenco e spenta senza sorgente
assert "— nessuna (non calibrata) —" in PAGINA and "— risoluzione diversa" in PAGINA
assert "disegnaSceltaCal();" in PAGINA and "= !$(\"sorgente\").value" in PAGINA
+2
View File
@@ -250,6 +250,8 @@ async def create_task(
file_type=data.file_type,
annotations_json=data.annotations_json,
vision_json=data.vision_json,
# #97: camera e modo di analisi (dict gia' validato dal modello)
acquisition_json=data.acquisition_json.model_dump() if data.acquisition_json else None,
# #53 #55: ciclo (default dal tipo), chiusura, campi extra
in_loop=data.in_loop if data.in_loop is not None else default_in_loop(data.task_type),
at_close=data.at_close,
+30 -6
View File
@@ -23,7 +23,7 @@ from src.backend.api.routers.files import (
from src.backend.config import settings
from src.backend.database import get_db
from src.backend.models.api.vision import (
VisionExecuteResponse, VisionMeasurementResult, VisionRecipeResponse,
VisionCheckResponse, VisionExecuteResponse, VisionMeasurementResult, VisionRecipeResponse,
VisionRecipeUploadResponse, VisionReferenceImageResponse,
)
from src.backend.models.orm.vision import VisionResult
@@ -40,14 +40,22 @@ _IMAGE_EXTENSIONS = {
"image/png": "png",
"image/gif": "gif",
"image/webp": "webp",
"image/bmp": "bmp", # #99
"image/x-ms-bmp": "bmp", # #99
}
# #99: the Opto bench cameras save BMP and the worker decodes it (PIL), so the
# vision routes accept it on top of files.py's list. files.py is untouched: its
# attachments are served by extension and BMP is not meant to be shown there.
_VISION_IMAGE_TYPES = ALLOWED_IMAGE_TYPES | {"image/bmp", "image/x-ms-bmp"}
# Sniffed from the first bytes, independent of the declared Content-Type: a
# text file uploaded as "image/png" passes the header check above and must
# still be caught before it reaches the worker as a bare 500 (#15). BMP and
# TIFF are included even though `_IMAGE_EXTENSIONS` does not accept them,
# because PIL - and so the worker - can decode both; this check only refuses
# what is not a real image at all, it does not narrow the allow-list above.
# still be caught before it reaches the worker as a bare 500 (#15). TIFF is
# included even though the allow-list does not accept it (PIL, and so the
# worker, can decode it; browsers do not show it in the result); BMP is now
# also allowed (#99). This check only refuses what is not a real image at all,
# it does not narrow the allow-list above.
_IMAGE_SIGNATURES: tuple[tuple[bytes, ...], ...] = (
(b"\x89PNG\r\n\x1a\n",), # PNG
(b"\xff\xd8\xff",), # JPEG
@@ -83,7 +91,7 @@ async def _read_image(image: UploadFile) -> bytes:
is in hand - the same two-step convention `files.py::upload_file` already
uses, reused rather than reinvented here.
"""
if image.content_type not in ALLOWED_IMAGE_TYPES:
if image.content_type not in _VISION_IMAGE_TYPES: # #99
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File type {image.content_type} not allowed. Must be an image.",
@@ -164,6 +172,22 @@ async def execute(
)
@router.post("/check", response_model=VisionCheckResponse)
async def check(
task_id: int = Form(...),
image: UploadFile = File(...),
production_run_id: int | None = Form(None, gt=0),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_measurement_tec),
) -> VisionCheckResponse:
"""#97: analisi di un frame senza salvare nulla (ne' risultato ne' misure)."""
task = await _task_or_404(db, task_id)
report = await vision_service.check_task(
db, task, await _read_image(image), production_run_id=production_run_id,
)
return VisionCheckResponse(**report)
@router.get("/results/{result_id}/image")
async def get_result_image(
result_id: int,
@@ -0,0 +1,31 @@
"""recipe_tasks.acquisition_json (#97)
recipe_tasks.acquisition_json: camera e modo di analisi scelti nel task,
{"camera_url", "camera_source", "modo": "impulsiva" | "in_linea"}; NULL = la
camera della stazione dell'operatore, analisi impulsiva (come prima).
Revision ID: 021_task_acquisition
Revises: 020_station_camera
Create Date: 2026-10-24
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '021_task_acquisition'
down_revision: Union[str, None] = '020_station_camera'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# batch_alter_table come nelle migrazioni vicine: serve a SQLite
with op.batch_alter_table('recipe_tasks') as batch:
batch.add_column(sa.Column('acquisition_json', sa.JSON(), nullable=True))
def downgrade() -> None:
with op.batch_alter_table('recipe_tasks') as batch:
batch.drop_column('acquisition_json')
+29 -1
View File
@@ -2,8 +2,9 @@
import re
from typing import Any, Literal, Optional
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from src.backend.models.api.station import _normalize_http_url
from src.backend.models.orm.task import DEFAULT_LOOP_TASK_TYPES, TASK_TYPES
# Kept in step with the ORM enum so the two cannot drift apart.
@@ -159,6 +160,30 @@ class SubtaskResponse(BaseModel):
vision_output: Optional[str] = None
class AcquisitionConfig(BaseModel):
"""#97: camera (servizio + sorgente) e modo di analisi scelti nel task."""
camera_url: str = Field(..., max_length=500)
camera_source: str = Field(..., min_length=1, max_length=200)
modo: Literal["impulsiva", "in_linea"] = "impulsiva"
@field_validator("camera_url")
@classmethod
def _check_camera_url(cls, value: str) -> str:
# stessa normalizzazione delle stazioni; qui il vuoto non e' ammesso
normalized = _normalize_http_url(value, "camera_url")
if normalized is None:
raise ValueError("camera_url must not be empty")
return normalized
@field_validator("camera_source")
@classmethod
def _check_camera_source(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("camera_source must not be empty")
return value
class TaskCreate(BaseModel):
"""Schema for creating a task."""
title: str = Field(..., min_length=1, max_length=255)
@@ -169,6 +194,7 @@ class TaskCreate(BaseModel):
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
annotations_json: Optional[dict[str, Any]] = None
vision_json: Optional[dict] = None
acquisition_json: Optional[AcquisitionConfig] = None # #97
# #53: assente = default dal tipo (misure e check nel ciclo)
in_loop: Optional[bool] = None
at_close: bool = False
@@ -186,6 +212,7 @@ class TaskUpdate(BaseModel):
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
annotations_json: Optional[dict[str, Any]] = None
vision_json: Optional[dict] = None
acquisition_json: Optional[AcquisitionConfig] = None # #97
in_loop: Optional[bool] = None
at_close: Optional[bool] = None
fields: Optional[list[dict[str, Any]]] = None
@@ -207,6 +234,7 @@ class TaskResponse(BaseModel):
file_type: Optional[str] = None
annotations_json: Optional[dict[str, Any]] = None
vision_json: Optional[dict] = None
acquisition_json: Optional[AcquisitionConfig] = None # #97
in_loop: bool = False
at_close: bool = False
fields: Optional[list[dict[str, Any]]] = None
+10
View File
@@ -24,6 +24,16 @@ class VisionExecuteResponse(BaseModel):
failures: list[dict] = []
class VisionCheckResponse(BaseModel):
"""What `POST /api/vision/check` returns (#97): the analysis, nothing saved."""
outcome: str | None = None
overlay: list[dict] = []
failures: list[dict] = []
measurements: list[VisionMeasurementResult] = []
# esito della lettura della calibrazione: ok | no_camera | not_calibrated | unreachable
calibration: str
class VisionRecipeOutput(BaseModel):
"""One numeric output a recipe offers to the quotes."""
key: str
+3
View File
@@ -65,6 +65,9 @@ class RecipeTask(Base):
# Cartella immutabile (relativa a upload_path) con recipe.json e i file di
# meta della ricetta VisionSuite v2 (#46).
vision_recipe_dir: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
# #97: camera e modo di analisi del task, {camera_url, camera_source, modo};
# NULL = camera della stazione corrente, analisi impulsiva.
acquisition_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
# #53: il task fa parte del ciclo di produzione (default dal tipo: misure e
# check si', gli altri no; un task 'note' si puo' mettere nel ciclo a mano).
in_loop: Mapped[bool] = mapped_column(
+2
View File
@@ -117,6 +117,8 @@ async def _copy_tasks_to_version(
vision_json=task.vision_json,
# Cartella immutabile: basta il riferimento (#46).
vision_recipe_dir=task.vision_recipe_dir,
# #97: camera e modo di analisi seguono il task nella nuova versione.
acquisition_json=task.acquisition_json,
# #53 #55: ciclo, chiusura e campi extra seguono il task nella nuova versione.
in_loop=task.in_loop,
at_close=task.at_close,
+122 -14
View File
@@ -231,35 +231,36 @@ CAL_NOT_CALIBRATED = "not_calibrated" # l'acquisitore risponde 404 (o un valore
CAL_UNREACHABLE = "unreachable" # l'acquisitore non risponde
async def fetch_station_calibration(station: Station | None) -> tuple[dict | None, str]:
"""Legge la calibrazione dall'acquisitore della stazione (C1, #83).
async def fetch_calibration(
camera_url: str | None, camera_source: str | None, label: str = "",
) -> tuple[dict | None, str]:
"""Legge la calibrazione da un acquisitore (C1, #83; generalizzata in #97).
GET {camera_url}/camera/calibration?source={camera_source}. La calibrazione
vive nell'acquisitore: TMF la legge, non la tiene. Ritorna (dict, "ok")
oppure (None, motivo) per distinguere «non calibrata» da «irraggiungibile».
Un px_per_mm non finito o <= 0 conta come non calibrata.
Un px_per_mm non finito o <= 0 conta come non calibrata. `label` serve
solo ai log (stazione o task).
"""
if station is None or not station.camera_url or not station.camera_source:
if not camera_url or not camera_source:
return None, CAL_NO_CAMERA
url = f"{station.camera_url.rstrip('/')}/camera/calibration"
url = f"{camera_url.rstrip('/')}/camera/calibration"
try:
# timeout breve: e' una lettura di servizio, non deve bloccare l'execute
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(url, params={"source": station.camera_source})
response = await client.get(url, params={"source": camera_source})
except httpx.HTTPError as exc:
logger.warning("acquisitore della stazione %s non raggiungibile: %s", station.code, exc)
logger.warning("acquisitore %s non raggiungibile: %s", label, exc)
return None, CAL_UNREACHABLE
if response.status_code == status.HTTP_404_NOT_FOUND:
return None, CAL_NOT_CALIBRATED
if response.status_code != 200:
logger.warning(
"acquisitore della stazione %s: HTTP %s", station.code, response.status_code,
)
logger.warning("acquisitore %s: HTTP %s", label, response.status_code)
return None, CAL_UNREACHABLE
try:
body = response.json()
except ValueError:
logger.warning("acquisitore della stazione %s: risposta non JSON", station.code)
logger.warning("acquisitore %s: risposta non JSON", label)
return None, CAL_UNREACHABLE
px_per_mm = body.get("px_per_mm") if isinstance(body, dict) else None
if not (_is_measurable(px_per_mm) and px_per_mm > 0):
@@ -267,6 +268,33 @@ async def fetch_station_calibration(station: Station | None) -> tuple[dict | Non
return body, CAL_OK
async def fetch_station_calibration(station: Station | None) -> tuple[dict | None, str]:
"""Calibrazione dall'acquisitore della stazione (C1, #83): vedi fetch_calibration."""
if station is None:
return None, CAL_NO_CAMERA
return await fetch_calibration(
station.camera_url, station.camera_source, f"della stazione {station.code}",
)
def task_camera(task: RecipeTask, station: Station | None) -> tuple[str | None, str | None]:
"""Camera di un task (#97): acquisition_json, altrimenti quella della stazione."""
acquisition = task.acquisition_json
if isinstance(acquisition, dict) and acquisition.get("camera_url") and acquisition.get("camera_source"):
return acquisition["camera_url"], acquisition["camera_source"]
if station is not None:
return station.camera_url, station.camera_source
return None, None
async def fetch_task_calibration(
task: RecipeTask, station: Station | None,
) -> tuple[dict | None, str]:
"""Calibrazione della camera del task (#97), stessi esiti CAL_* di fetch_calibration."""
camera_url, camera_source = task_camera(task, station)
return await fetch_calibration(camera_url, camera_source, f"del task {task.id}")
async def get_station_calibration(station: Station | None) -> dict | None:
"""La calibrazione C1 della stazione, o None in ogni altro caso (#83)."""
calibration, _ = await fetch_station_calibration(station)
@@ -498,14 +526,15 @@ async def execute_task(
)
# Worker della stazione della sessione di produzione (#45); senza run o senza URL: centrale
station_url = None
# Calibrazione letta dall'acquisitore della stazione (#83); None = non c'e'
calibration = None
# Calibrazione letta dall'acquisitore (#83); None = non c'e'. #97: la camera e'
# quella del task se la ricetta ne sceglie una, altrimenti quella della stazione.
station = None
if run is not None:
station = (await db.execute(
select(Station).where(Station.id == run.station_id)
)).scalar_one_or_none()
station_url = (station.vision_worker_url or None) if station is not None else None
calibration = await get_station_calibration(station)
calibration, _ = await fetch_task_calibration(task, station)
if not task.vision_json:
raise HTTPException(
@@ -623,6 +652,85 @@ async def execute_task(
return result, saved, report
async def check_task(
db: AsyncSession,
task: RecipeTask,
image_bytes: bytes,
*,
production_run_id: int | None = None,
) -> dict:
"""Esegue il grafo come execute_task ma NON salva nulla (#97).
Anteprima per l'operatore (ingresso nel task, ciclo «in linea»): niente
VisionResult, niente file, niente misure. L'esito per quota usa
calculate_pass_fail sulle quote con vision_output, nell'ordine di
marker_number come execute. Le quote che il grafo non produce (pezzo non
ancora in posizione) restano fuori da `measurements` e il perche' sta in
`failures`: un'anteprima non deve dare 422 a ogni frame.
"""
if task.task_type not in VISION_TASK_TYPES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"task type {task.task_type!r} is not a camera measurement",
)
station = None
station_url = None
if production_run_id is not None:
# stesse regole di execute: 404 / 422 / 409 sulla sessione di produzione
run = await measurement_service.validate_run_for_version(
db, production_run_id, task.version_id,
)
station = (await db.execute(
select(Station).where(Station.id == run.station_id)
)).scalar_one_or_none()
station_url = (station.vision_worker_url or None) if station is not None else None
if not task.vision_json:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="this task carries no vision graph",
)
quotes = (await db.execute(
select(RecipeSubtask)
.where(RecipeSubtask.task_id == task.id)
.where(RecipeSubtask.vision_output.is_not(None))
.order_by(RecipeSubtask.marker_number)
)).scalars().all()
if not quotes:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="no quote on this task names a vision output",
)
# stessa calibrazione di execute: camera del task, altrimenti della stazione
calibration, cal_reason = await fetch_task_calibration(task, station)
worker_kwargs: dict = {}
if calibration is not None:
worker_kwargs["calibration"] = {"px_per_mm": float(calibration["px_per_mm"])}
report = await _call_worker(
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
worker_url=station_url,
**worker_kwargs,
)
outputs = report["outputs"]
measurements = [
{
"subtask_id": q.id,
"value": float(outputs[q.vision_output]),
"pass_fail": measurement_service.calculate_pass_fail(
float(outputs[q.vision_output]), q,
)[0],
}
for q in quotes if _is_measurable(outputs.get(q.vision_output))
]
return {
"outcome": report.get("outcome"),
"overlay": report.get("overlay") or [],
"failures": report.get("failures") or [],
"measurements": measurements,
"calibration": cal_reason,
}
def _reference_file_path(reference: VisionReferenceImage) -> Path:
return settings.upload_path / reference.path
+19
View File
@@ -341,3 +341,22 @@ class TestStationCameraMigration:
assert "vision_worker_url" in cols("stations") # la 019 resta
command.upgrade(cfg, "head") # e di nuovo su
assert {"camera_url", "camera_source"} <= cols("stations")
class TestTaskAcquisitionMigration:
"""021_task_acquisition: recipe_tasks.acquisition_json, su e giu (#97)."""
def test_021_upgrade_and_downgrade(self, sqlite_database_url):
db_path, _ = sqlite_database_url
cfg = _alembic_config()
def cols(table):
return {c["name"] for c in _inspect(db_path).get_columns(table)}
command.upgrade(cfg, "head")
assert "acquisition_json" in cols("recipe_tasks")
command.downgrade(cfg, "020_station_camera")
assert "acquisition_json" not in cols("recipe_tasks")
assert "camera_url" in cols("stations") # la 020 resta
command.upgrade(cfg, "head") # e di nuovo su
assert "acquisition_json" in cols("recipe_tasks")
+308
View File
@@ -0,0 +1,308 @@
"""#97: camera e modo di analisi nel task, POST /api/vision/check senza salvataggio.
Il worker e l'acquisitore non girano nei test: si fingono (fixture di
test_vision_dxf_compare e test_vision_station_worker).
"""
import pytest
from sqlalchemy import func, select
from src.backend.config import settings
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from src.backend.models.orm.vision import VisionResult
from src.backend.tests.conftest import auth_headers, create_test_recipe
from src.backend.tests.test_vision_copy_on_write import (
_current_task_and_subtask, _seed_measurement,
)
from src.backend.tests.test_vision_dxf_compare import ( # noqa: F401 (fixture acquisitore)
CAL, CAMERA_URL, acquisitore, redirect_uploads,
)
from src.backend.tests.test_vision_execute import an_image
from src.backend.tests.test_vision_station_worker import ( # noqa: F401 (fixture worker_calls)
_camera_task, _execute, _station_with_run, worker_calls,
)
TASK_CAMERA = "http://10.0.0.50:8300"
ACQ = {"camera_url": TASK_CAMERA, "camera_source": "camera:601020", "modo": "in_linea"}
async def _task_with_acquisition(db_session, user_id, acquisition=ACQ):
recipe, task = await _camera_task(db_session, user_id)
task.acquisition_json = acquisition
await db_session.commit()
return recipe, task
# --- modello e API del task ---------------------------------------------------
@pytest.mark.asyncio
async def test_create_task_stores_and_normalizes_acquisition(client, db_session, maker_user):
recipe = await create_test_recipe(db_session, maker_user.id)
resp = await client.post(
f"/api/recipes/{recipe.id}/tasks",
headers=auth_headers(maker_user),
json={
"title": "Camera", "task_type": "camera_measure",
"acquisition_json": {"camera_url": " http://h:1/ ", "camera_source": " camera:1 "},
},
)
assert resp.status_code == 201, resp.text
body = resp.json()
# url normalizzato come nelle stazioni, sorgente senza spazi, modo di default
expected = {"camera_url": "http://h:1", "camera_source": "camera:1", "modo": "impulsiva"}
assert body["acquisition_json"] == expected
db_session.expunge_all()
task = (await db_session.execute(
select(RecipeTask).where(RecipeTask.id == body["id"])
)).scalar_one()
assert task.acquisition_json == expected # sul DB, non solo nella risposta
got = await client.get(f"/api/tasks/{body['id']}", headers=auth_headers(maker_user))
assert got.json()["acquisition_json"] == expected
@pytest.mark.asyncio
async def test_task_without_acquisition_is_null(client, db_session, maker_user):
recipe = await create_test_recipe(db_session, maker_user.id)
resp = await client.get(f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user))
assert resp.json()[0]["acquisition_json"] is None
@pytest.mark.asyncio
@pytest.mark.parametrize("bad", [
{"camera_url": "ftp://h", "camera_source": "camera:1"},
{"camera_url": " ", "camera_source": "camera:1"},
{"camera_url": "http://h", "camera_source": " "},
{"camera_url": "http://h", "camera_source": "x" * 201},
{"camera_url": "http://h", "camera_source": "camera:1", "modo": "continua"},
{"camera_source": "camera:1"},
])
async def test_invalid_acquisition_is_422(client, db_session, maker_user, bad):
recipe = await create_test_recipe(db_session, maker_user.id)
resp = await client.post(
f"/api/recipes/{recipe.id}/tasks",
headers=auth_headers(maker_user),
json={"title": "Camera", "task_type": "camera_measure", "acquisition_json": bad},
)
assert resp.status_code == 422, resp.text
@pytest.mark.asyncio
async def test_update_sets_and_clears_acquisition(client, db_session, maker_user):
recipe = await create_test_recipe(db_session, maker_user.id)
_, task, _ = await _current_task_and_subtask(db_session, recipe.id)
resp = await client.put(
f"/api/tasks/{task.id}", headers=auth_headers(maker_user), json={"acquisition_json": ACQ},
)
assert resp.status_code == 200, resp.text
assert resp.json()["acquisition_json"] == ACQ
resp = await client.put(
f"/api/tasks/{task.id}", headers=auth_headers(maker_user), json={"acquisition_json": None},
)
assert resp.status_code == 200
assert resp.json()["acquisition_json"] is None # null = camera della stazione
@pytest.mark.asyncio
async def test_copy_on_write_preserves_acquisition(client, db_session, maker_user):
recipe = await create_test_recipe(db_session, maker_user.id)
_, task, _ = await _current_task_and_subtask(db_session, recipe.id)
task.acquisition_json = ACQ
await db_session.flush()
await _seed_measurement(db_session, recipe.id, maker_user.id)
db_session.expunge_all()
resp = await client.put(
f"/api/recipes/{recipe.id}", headers=auth_headers(maker_user),
json={"change_notes": "forza il copy-on-write"},
)
assert resp.status_code == 200
db_session.expunge_all()
version, new_task, _ = await _current_task_and_subtask(db_session, recipe.id)
assert version.version_number == 2
assert new_task.acquisition_json == ACQ
# --- calibrazione dalla camera del task ----------------------------------------
@pytest.mark.asyncio
async def test_execute_reads_calibration_from_the_task_camera(
client, db_session, measurement_tec_user, worker_calls, acquisitore,
):
recipe, task = await _task_with_acquisition(db_session, measurement_tec_user.id)
# stazione con camera propria: la camera del task prevale
station, run = await _station_with_run(db_session, measurement_tec_user, recipe, None)
station.camera_url, station.camera_source = CAMERA_URL, "camera:A1"
await db_session.commit()
await _execute(client, measurement_tec_user, task, run)
assert acquisitore["urls"] == [(f"{TASK_CAMERA}/camera/calibration", {"source": "camera:601020"})]
assert worker_calls[0]["calibration"] == {"px_per_mm": CAL["px_per_mm"]}
result = (await db_session.execute(select(VisionResult))).scalars().one()
assert result.calibration_snapshot["px_per_mm"] == CAL["px_per_mm"]
@pytest.mark.asyncio
async def test_execute_task_camera_works_without_a_run(
client, db_session, measurement_tec_user, worker_calls, acquisitore,
):
_, task = await _task_with_acquisition(db_session, measurement_tec_user.id)
await _execute(client, measurement_tec_user, task) # niente sessione di produzione
assert acquisitore["urls"][0][0] == f"{TASK_CAMERA}/camera/calibration"
assert "calibration" in worker_calls[0]
@pytest.mark.asyncio
async def test_execute_falls_back_to_the_station_camera(
client, db_session, measurement_tec_user, worker_calls, acquisitore,
):
recipe, task = await _camera_task(db_session, measurement_tec_user.id) # acquisition_json null
station, run = await _station_with_run(db_session, measurement_tec_user, recipe, None)
station.camera_url, station.camera_source = CAMERA_URL, "camera:A1"
await db_session.commit()
await _execute(client, measurement_tec_user, task, run)
assert acquisitore["urls"] == [(f"{CAMERA_URL}/camera/calibration", {"source": "camera:A1"})]
assert worker_calls[0]["calibration"] == {"px_per_mm": CAL["px_per_mm"]}
# --- POST /api/vision/check ----------------------------------------------------
async def _check(client, user, task, run=None):
data = {"task_id": str(task.id)}
if run is not None:
data["production_run_id"] = str(run.id)
return await client.post(
"/api/vision/check", headers=auth_headers(user),
files={"image": ("p.png", an_image(), "image/png")}, data=data,
)
async def _counts(db_session):
return tuple([
(await db_session.execute(select(func.count()).select_from(model))).scalar_one()
for model in (Measurement, VisionResult)
])
@pytest.mark.asyncio
async def test_check_saves_nothing_and_reports_pass_fail(
client, db_session, measurement_tec_user, monkeypatch, acquisitore,
):
_, task = await _task_with_acquisition(db_session, measurement_tec_user.id)
seen = {}
async def fake_call(image_bytes, graph, **kwargs):
seen.update(kwargs)
return {"outputs": {"t1.diameter": 11.0}, "failures": [{"tool_name": "t"}],
"overlay": [{"kind": "line"}], "outcome": "fail",
"engine_version": "e" * 40, "duration_ms": 3}
monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call)
before = await _counts(db_session)
resp = await _check(client, measurement_tec_user, task)
assert resp.status_code == 200, resp.text
body = resp.json()
quote = (await db_session.execute(
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
)).scalars().one()
assert body == {
"outcome": "fail", "overlay": [{"kind": "line"}], "failures": [{"tool_name": "t"}],
"measurements": [{"subtask_id": quote.id, "value": 11.0, "pass_fail": "fail"}],
"calibration": "ok",
}
assert seen["calibration"] == {"px_per_mm": CAL["px_per_mm"]} # stessa calibrazione di execute
assert await _counts(db_session) == before # niente misure ne' VisionResult
assert not list(settings.upload_path.rglob("*.overlay.json")) # ne' file
@pytest.mark.asyncio
@pytest.mark.parametrize("value,expected", [(10.0, "pass"), (10.4, "warning"), (9.0, "fail")])
async def test_check_pass_fail_levels(
client, db_session, measurement_tec_user, monkeypatch, value, expected,
):
_, task = await _camera_task(db_session, measurement_tec_user.id)
async def fake_call(image_bytes, graph, **kwargs):
return {"outputs": {"t1.diameter": value}, "failures": [], "engine_version": "e" * 40}
monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call)
resp = await _check(client, measurement_tec_user, task)
assert resp.json()["measurements"][0]["pass_fail"] == expected
assert resp.json()["calibration"] == "no_camera" # senza camera ne' stazione
@pytest.mark.asyncio
async def test_check_calibration_reasons(
client, db_session, measurement_tec_user, worker_calls, acquisitore,
):
_, task = await _task_with_acquisition(db_session, measurement_tec_user.id)
acquisitore["cal"] = None
assert (await _check(client, measurement_tec_user, task)).json()["calibration"] == "not_calibrated"
acquisitore["cal"] = "down"
assert (await _check(client, measurement_tec_user, task)).json()["calibration"] == "unreachable"
assert "calibration" not in worker_calls[-1] # senza calibrazione la ricetta usa la sua
@pytest.mark.asyncio
async def test_check_omits_quotes_the_graph_did_not_produce(
client, db_session, measurement_tec_user, monkeypatch,
):
_, task = await _camera_task(db_session, measurement_tec_user.id)
async def fake_call(image_bytes, graph, **kwargs):
return {"outputs": {}, "failures": [{"tool_name": "blob"}], "engine_version": "e" * 40}
monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call)
resp = await _check(client, measurement_tec_user, task)
assert resp.status_code == 200 # un'anteprima non da' 422 a ogni frame
assert resp.json()["measurements"] == []
assert resp.json()["failures"] == [{"tool_name": "blob"}]
@pytest.mark.asyncio
async def test_check_422_without_graph_or_quotes_or_vision_type(
client, db_session, measurement_tec_user, worker_calls,
):
_, task = await _camera_task(db_session, measurement_tec_user.id)
task.vision_json = None
await db_session.commit()
assert (await _check(client, measurement_tec_user, task)).status_code == 422
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
quote = (await db_session.execute(
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
)).scalars().one()
quote.vision_output = None
await db_session.commit()
assert (await _check(client, measurement_tec_user, task)).status_code == 422
task.task_type = "measure"
await db_session.commit()
assert (await _check(client, measurement_tec_user, task)).status_code == 422
@pytest.mark.asyncio
async def test_check_with_run_uses_station_worker_and_station_camera(
client, db_session, measurement_tec_user, worker_calls, acquisitore,
):
recipe, task = await _camera_task(db_session, measurement_tec_user.id)
station, run = await _station_with_run(
db_session, measurement_tec_user, recipe, "http://10.0.0.7:8100")
station.camera_url, station.camera_source = CAMERA_URL, "camera:A1"
await db_session.commit()
before = await _counts(db_session)
resp = await _check(client, measurement_tec_user, task, run)
assert resp.status_code == 200, resp.text
assert worker_calls[0]["worker_url"] == "http://10.0.0.7:8100"
assert acquisitore["urls"][0][0] == f"{CAMERA_URL}/camera/calibration"
assert await _counts(db_session) == before
@pytest.mark.asyncio
async def test_check_requires_login(client, db_session, measurement_tec_user, worker_calls):
_, task = await _camera_task(db_session, measurement_tec_user.id)
resp = await client.post(
"/api/vision/check", files={"image": ("p.png", an_image(), "image/png")},
data={"task_id": str(task.id)},
)
assert resp.status_code in (401, 403)
+109
View File
@@ -0,0 +1,109 @@
"""#99: the vision routes accept BMP (the Opto bench cameras save it); TIFF and
non-images stay refused. files.py (attachments) is not touched by this."""
import io
import pytest
from PIL import Image
from sqlalchemy import select
from src.backend.config import settings
from src.backend.models.orm.vision import VisionReferenceImage
from src.backend.tests.conftest import auth_headers, create_test_recipe
from src.backend.tests.test_vision_upload_limits import ( # noqa: F401 - fixtures
recipe_parts, redirect_uploads, worker_says,
)
def a_bmp() -> bytes:
buffer = io.BytesIO()
Image.new("L", (64, 64), color=0).save(buffer, format="BMP")
return buffer.getvalue()
def a_tiff() -> bytes:
buffer = io.BytesIO()
Image.new("L", (64, 64), color=0).save(buffer, format="TIFF")
return buffer.getvalue()
async def a_camera_task(db_session, user):
recipe = await create_test_recipe(db_session, user.id)
_, task, _ = await recipe_parts(db_session, recipe.id)
task.task_type = "camera_measure"
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
await db_session.commit()
return task
@pytest.mark.asyncio
@pytest.mark.parametrize("content_type", ["image/bmp", "image/x-ms-bmp"])
async def test_preview_accepts_a_bmp(
client, db_session, maker_user, worker_says, content_type,
):
task = await a_camera_task(db_session, maker_user)
worker_says({"t1.diameter": 10.0})
response = await client.post(
"/api/vision/preview",
headers=auth_headers(maker_user),
files={"image": ("p.bmp", a_bmp(), content_type)},
data={"task_id": str(task.id)},
)
assert response.status_code != 400, response.text
assert response.status_code != 422, response.text
@pytest.mark.asyncio
async def test_preview_still_rejects_a_tiff(
client, db_session, maker_user, worker_says,
):
task = await a_camera_task(db_session, maker_user)
worker_says({"t1.diameter": 10.0})
response = await client.post(
"/api/vision/preview",
headers=auth_headers(maker_user),
files={"image": ("p.tif", a_tiff(), "image/tiff")},
data={"task_id": str(task.id)},
)
assert response.status_code == 400
@pytest.mark.asyncio
async def test_a_text_file_declared_bmp_is_refused_on_its_bytes(
client, db_session, maker_user, worker_says,
):
task = await a_camera_task(db_session, maker_user)
worker_says({"t1.diameter": 10.0})
response = await client.post(
"/api/vision/preview",
headers=auth_headers(maker_user),
files={"image": ("p.bmp", b"i am not an image", "image/bmp")},
data={"task_id": str(task.id)},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_reference_image_bmp_is_saved_with_the_bmp_extension(
client, db_session, maker_user,
):
recipe = await create_test_recipe(db_session, maker_user.id)
_, task, _ = await recipe_parts(db_session, recipe.id)
await db_session.commit()
response = await client.post(
"/api/vision/reference-images",
headers=auth_headers(maker_user),
files={"image": ("p.bmp", a_bmp(), "image/x-ms-bmp")},
data={"task_id": str(task.id)},
)
assert response.status_code == 200, response.text
saved = (await db_session.execute(select(VisionReferenceImage))).scalars().one()
assert saved.path.endswith(".bmp")
assert (settings.upload_path / saved.path).exists()
@@ -541,6 +541,55 @@ def api_list_stations_for_maker():
return jsonify(out), 200
def _camera_services() -> list[dict]:
"""#97: servizi di acquisizione distinti delle stazioni che hanno una camera_url."""
services: dict[str, list[str]] = {}
for st in _stations_for_maker():
url = (st.get("camera_url") or "").strip().rstrip("/")
if not url:
continue
label = f"{st.get('code') or ''} - {st.get('name') or ''}"
services.setdefault(url, []).append(label)
return [{"camera_url": u, "stazioni": s} for u, s in services.items()]
@maker_bp.route("/api/camera/servizi", methods=["GET"])
@login_required
@maker_or_admin_required
def api_camera_services():
"""#97: servizi di acquisizione fra cui scegliere la camera di un task."""
return jsonify({"servizi": _camera_services()}), 200
@maker_bp.route("/api/camera/sorgenti", methods=["GET"])
@login_required
@maker_or_admin_required
def api_camera_sources():
"""#97: proxy di GET {url}/camera/sources; solo per i servizi delle stazioni."""
url = (request.args.get("url") or "").strip().rstrip("/")
# Niente richieste verso indirizzi arbitrari: url ammesso solo se e' un servizio noto
if not url or url not in {s["camera_url"] for s in _camera_services()}:
return jsonify({
"error": True,
"detail": _("Servizio di acquisizione non ammesso"),
}), 400
try:
resp = http_requests.get(f"{url}/camera/sources", timeout=15)
body = resp.json() if resp.status_code == 200 else None
except (http_requests.RequestException, ValueError) as exc:
return jsonify({
"error": True,
"detail": _("Servizio camera non raggiungibile: %(detail)s", detail=str(exc)),
}), 502
sources = body.get("sources") if isinstance(body, dict) else None
if not isinstance(sources, list):
return jsonify({
"error": True,
"detail": _("Risposta del servizio camera non valida (%(status)s)", status=resp.status_code),
}), 502
return jsonify({"sources": sources}), 200
# Copia minima della logica di measure.api_vision_grab, ma per una stazione scelta per id
@maker_bp.route("/api/stations/<int:station_id>/camera/grab", methods=["GET", "POST"])
@login_required
+82 -2
View File
@@ -1,4 +1,5 @@
"""MeasurementTec blueprint - recipe selection and measurement execution."""
import json
import urllib.parse
import requests as http_requests
@@ -337,6 +338,14 @@ def task_execute(task_id: int):
measurement_interval_minutes = recipe_resp.get("measurement_interval_minutes")
allow_manual_input = bool(recipe_resp.get("allow_manual_input", True))
# #97: camera del task (acquisition_json) o della stazione; il modo di analisi
# (impulsiva / in_linea) viene dal task. Solo i task visione ne hanno bisogno.
camera = None
camera_mode = "impulsiva"
if task_resp.get("task_type") in ("camera_measure", "dxf_compare"):
camera = _task_camera(task_resp, _current_station()[0])
camera_mode = _task_mode(task_resp)
return render_template(
"measure/task_execute.html",
task=task_resp,
@@ -345,7 +354,8 @@ def task_execute(task_id: int):
all_task_ids=all_task_ids,
measurement_interval_minutes=measurement_interval_minutes,
allow_manual_input=allow_manual_input,
camera_enabled=_station_camera(_current_station()[0]) is not None,
camera_enabled=camera is not None,
camera_mode=camera_mode,
)
@@ -1001,6 +1011,42 @@ def api_vision_execute():
return jsonify(resp), 200
# #97: analysis of a frame without saving anything (preview before «Registra»).
@measure_bp.route("/api/vision/check", methods=["POST"])
@login_required
@role_required("MeasurementTec")
def api_vision_check():
"""Proxy: run the task's VisionSuite recipe on the image, nothing is saved."""
image = request.files.get("image")
task_id = request.form.get("task_id")
if image is None or not task_id:
return jsonify({
"error": True,
"detail": _("Dati mancanti: task_id e image sono obbligatori"),
}), 400
form = {"task_id": task_id}
if request.form.get("production_run_id"):
form["production_run_id"] = request.form["production_run_id"]
resp = api_client.post(
"/api/vision/check",
data=form,
files={"image": (
image.filename or "capture.jpg",
image.read(),
image.mimetype or "application/octet-stream",
)},
)
if resp.get("error"):
status_code = resp.get("status_code", 500)
body = {"error": True, "detail": resp.get("detail", _("Errore nell'esecuzione"))}
if resp.get("session_expired"):
body["session_expired"] = True
return jsonify(body), status_code if status_code >= 400 else 500
return jsonify(resp), 200
def _station_camera(station_code: str | None) -> tuple[str, str] | None:
"""(camera_url, camera_source) of the station, None when it has no camera.
@@ -1019,6 +1065,31 @@ def _station_camera(station_code: str | None) -> tuple[str, str] | None:
return str(url), str(source)
def _task_acquisition(task: dict | None) -> dict | None:
"""#97: acquisition_json of a task as a dict, None when absent or unreadable."""
acq = task.get("acquisition_json") if isinstance(task, dict) else None
if isinstance(acq, str):
try:
acq = json.loads(acq)
except ValueError:
return None
return acq if isinstance(acq, dict) else None
def _task_camera(task: dict | None, station_code: str | None) -> tuple[str, str] | None:
"""#97: camera of a task = its acquisition_json (url + source), else the station's."""
acq = _task_acquisition(task)
if acq and acq.get("camera_url") and acq.get("camera_source"):
return str(acq["camera_url"]), str(acq["camera_source"])
return _station_camera(station_code)
def _task_mode(task: dict | None) -> str:
"""#97: analysis mode of a task, «impulsiva» unless acquisition_json says «in_linea»."""
acq = _task_acquisition(task)
return "in_linea" if acq and acq.get("modo") == "in_linea" else "impulsiva"
# #72: the browser asks the station's camera service (vs-gui) for a frame; the
# service address is a server-side matter, so Flask relays and the PNG goes back as is.
@measure_bp.route("/api/vision/grab", methods=["POST"])
@@ -1026,7 +1097,16 @@ def _station_camera(station_code: str | None) -> tuple[str, str] | None:
@role_required("MeasurementTec")
def api_vision_grab():
"""Proxy: grab one frame from the current station's camera, answer a PNG."""
camera = _station_camera(_current_station()[0])
# #97: con task_id (form o json) la camera e' quella del task, altrimenti della stazione
payload = request.get_json(silent=True) or {}
task_id = request.form.get("task_id") or payload.get("task_id")
task = None
if task_id:
try:
task = api_client.get(f"/api/tasks/{int(task_id)}")
except (TypeError, ValueError):
task = None
camera = _task_camera(task, _current_station()[0])
if camera is None:
return jsonify({
"error": True,
@@ -0,0 +1,70 @@
// «Acquisizione» dell'editor task (#97): camera (servizio + sorgente) e modo di analisi
// («impulsiva» / «in linea») dei task camera_measure e dxf_compare.
// Legge /maker/api/camera/servizi e /maker/api/camera/sorgenti?url=. Lo stato scelto sta
// nel componente padre (editTaskData.acquisition): questo componente carica solo gli elenchi.
// Uso: x-data="taskAcquisition(() => editTaskData.acquisition, msgs)". Dipende da Alpine.js.
function taskAcquisition(getAcq, msgs) {
return {
msgs: msgs || {},
servizi: [],
sources: [],
loading: false,
error: '',
async init() {
await this.loadServizi();
// Task con camera gia' scelta: elenco sorgenti del suo servizio
const acq = getAcq();
if (acq && acq.camera_url) await this.loadSources(acq.camera_url);
},
async loadServizi() {
try {
const resp = await fetch('/maker/api/camera/servizi');
if (resp.ok) this.servizi = (await resp.json()).servizi || [];
} catch (e) { console.error('taskAcquisition servizi', e); }
},
// Servizio salvato nel task ma non piu' fra quelli delle stazioni: resta selezionabile
serviziConCorrente() {
const acq = getAcq();
const out = this.servizi.slice();
if (acq && acq.camera_url && !out.some(s => s.camera_url === acq.camera_url)) {
out.push({ camera_url: acq.camera_url, stazioni: [] });
}
return out;
},
// Cambio servizio: la sorgente scelta non vale piu'
async onServizio() {
const acq = getAcq();
acq.camera_source = '';
this.sources = [];
this.error = '';
if (acq.camera_url) await this.loadSources(acq.camera_url);
},
async loadSources(url) {
this.loading = true;
this.error = '';
try {
const resp = await fetch('/maker/api/camera/sorgenti?url=' + encodeURIComponent(url));
const data = await resp.json().catch(() => ({}));
if (resp.ok) this.sources = data.sources || [];
else this.error = data.detail || this.msgs.genericError || '';
} catch (e) {
this.error = this.msgs.genericError || String(e);
} finally { this.loading = false; }
},
// Sorgente salvata ma non piu' elencata dal servizio: resta visibile
sourcesConCorrente() {
const acq = getAcq();
const out = this.sources.slice();
if (acq && acq.camera_source && !out.some(s => s.name === acq.camera_source)) {
out.push({ name: acq.camera_source, label: acq.camera_source });
}
return out;
}
};
}
@@ -11,6 +11,13 @@
* taskType, subtasks, labels.
* Run context (lot, serial, production run) is read lazily through `ctx()`,
* which the template binds to the parent task component.
*
* #97: with a camera (cameraAvailable, the task's or the station's) the task opens
* on a frame already grabbed and analysed (/vision/check, nothing saved: «preview»).
* modo «impulsiva»: «Acquisisci» redoes grab+check. modo «in_linea»: continuous
* loop grab+check, one request at a time, standing still while paused / page hidden
* / leaving / saving. «Registra» saves the LAST frame shown through /vision/execute.
* Extra config: cameraAvailable, modo, checkUrl.
*/
function visionExecute() {
// Overlay colour per role (VisionSuite roles: reference, roi, found).
@@ -24,6 +31,125 @@ function visionExecute() {
imageSrc: '', // object URL of the acquired image (shown under the overlay)
ctx: () => ({}), // replaced by the template: {lot, serial, runId}
// #97: preview / loop state
preview: false, // result on screen is an analysis only, not saved
saved: false, // «Registra» done: the saved result replaces the preview
lastBlob: null, // last frame shown, the one «Registra» saves
paused: false, // operator paused the in-line loop
registering: false, // «Registra» in progress: the loop stands still
leaving: false, // page is being left: no more requests
looping: false, // an in-line loop is running
gen: 0, // bumped by «Registra»: answers of older requests are dropped
// #97: start on a frame already acquired and analysed; hook visibility and exit.
init() {
if (!this.hasCamera) return;
document.addEventListener('visibilitychange', () => { if (!document.hidden) this.kick(); });
const leave = () => { this.leaving = true; };
window.addEventListener('pagehide', leave);
window.addEventListener('beforeunload', leave);
// After x-init has bound ctx(): in-line starts the loop, impulsive grabs once.
this.$nextTick(() => { if (this.inLinea) this.loop(); else this.grab(); });
},
get hasCamera() { return !!(window.__visionCfg || {}).cameraAvailable; },
get inLinea() { return this.hasCamera && (window.__visionCfg || {}).modo === 'in_linea'; },
// «Registra» needs a frame on screen that is not saved yet
get canRecord() { return !!this.lastBlob && !this.saved && !this.registering; },
shouldRun() {
return this.inLinea && !this.paused && !this.saved && !this.registering
&& !this.leaving && !document.hidden;
},
// Page visible again: restart the loop unless the operator paused it.
kick() { if (this.shouldRun() && !this.looping) this.loop(); },
// In-line loop: one request at a time, at least 300 ms between two of them
// (longer after an error, so a dead camera is not hammered).
async loop() {
if (this.looping) return;
this.looping = true;
try {
while (this.shouldRun()) {
const ok = await this.grab();
await new Promise((r) => setTimeout(r, ok ? 300 : 2000));
}
} finally {
this.looping = false;
}
},
// «Pausa» / «Riprendi»; after «Registra» only «Riprendi» restarts the loop.
togglePause() {
if (this.paused || this.saved) {
this.paused = false;
this.saved = false;
this.loop();
} else {
this.paused = true;
}
},
// Analysis only (/vision/check): the frame becomes the preview, nothing is saved.
async analyze(blob) {
const cfg = window.__visionCfg || {};
if (!blob || !cfg.checkUrl) return false;
const gen = this.gen;
this.busy = true;
this.error = '';
try {
const c = this.ctx() || {};
const fd = new FormData();
fd.append('task_id', cfg.taskId);
fd.append('image', blob, blob.name || 'capture.jpg');
if (c.runId) fd.append('production_run_id', c.runId);
const csrf = document.querySelector('meta[name=csrf-token]')?.content || '';
const resp = await fetch(cfg.checkUrl, {
method: 'POST', headers: { 'X-CSRFToken': csrf }, body: fd,
});
const body = await resp.json().catch(() => ({}));
if (gen !== this.gen) return false; // «Registra» took over
if (!resp.ok || body.error) {
this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error';
return false;
}
if (this.imageSrc) URL.revokeObjectURL(this.imageSrc);
this.imageSrc = URL.createObjectURL(blob);
this.lastBlob = blob;
this.result = body;
this.preview = true;
this.saved = false;
this.$nextTick(() => this.drawOverlay());
return true;
} catch (e) {
if (gen === this.gen) this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
return false;
} finally {
if (gen === this.gen) this.busy = false;
}
},
// «Registra»: save the LAST frame shown with the usual /vision/execute.
async record() {
if (!this.canRecord) return;
const blob = this.lastBlob;
this.registering = true;
this.gen++; // drop whatever request is still in flight
this.busy = false;
this.grabbing = false;
try {
await this.acquire(blob);
if (!this.error && this.result && this.result.vision_result_id) {
this.preview = false;
this.saved = true;
}
} finally {
this.registering = false;
this.kick(); // in-line: stays still while saved, restarts if the save failed
}
},
// Entry point shared with the future agent camera.
async acquire(blob) {
if (!blob || this.busy) return;
@@ -64,37 +190,52 @@ function visionExecute() {
// Station camera (#72): the Flask proxy answers a PNG, which goes through
// acquire() like a picked file. Errors show as the execute ones do.
// #97: sends task_id (the task's camera wins over the station's); with a camera
// the frame goes to the preview analysis. Returns true when the frame was analysed.
async grab() {
const cfg = window.__visionCfg || {};
if (this.busy || this.grabbing || !cfg.grabUrl) return;
if (this.busy || this.grabbing || this.registering || !cfg.grabUrl) return false;
const gen = this.gen;
this.grabbing = true;
this.error = '';
let blob = null;
try {
const csrf = document.querySelector('meta[name=csrf-token]')?.content || '';
const fd = new FormData();
fd.append('task_id', cfg.taskId);
const resp = await fetch(cfg.grabUrl, {
method: 'POST', headers: { 'X-CSRFToken': csrf },
method: 'POST', headers: { 'X-CSRFToken': csrf }, body: fd,
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error';
return;
if (gen === this.gen) this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error';
return false;
}
blob = await resp.blob();
} catch (e) {
this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
return;
if (gen === this.gen) this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
return false;
} finally {
this.grabbing = false;
if (gen === this.gen) this.grabbing = false;
}
if (blob) await this.acquire(blob);
if (!blob || gen !== this.gen) return false;
if (this.hasCamera) return await this.analyze(blob);
await this.acquire(blob);
return !this.error;
},
// Input change: take the file and allow picking the same one again.
// #97: with a camera the file is a preview too (the loop pauses), «Registra» saves it.
onPick(ev) {
const f = ev.target.files && ev.target.files[0];
ev.target.value = '';
if (f) this.acquire(f);
if (!f) return;
if (this.hasCamera) {
if (this.inLinea) this.paused = true;
this.analyze(f);
} else {
this.acquire(f);
}
},
// Image shown: the saved one from the server when available, else the local one.
@@ -547,6 +547,71 @@
<div class="tmf-card-body space-y-4">
<!-- ========== Acquisizione (#97): camera e modo di analisi, solo camera_measure / dxf_compare ========== -->
<template x-if="task.task_type === 'camera_measure' || task.task_type === 'dxf_compare'">
<div class="p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]">
<p class="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wide mb-2">
{{ _('Acquisizione') }}
</p>
<!-- Lettura: camera e modo salvati nel task -->
<template x-if="editingTask !== task.id">
<div class="text-xs space-y-1">
<template x-if="task.acquisition_json && task.acquisition_json.camera_url">
<div class="space-y-1">
<p><span class="text-[var(--text-secondary)]">{{ _('Servizio di acquisizione') }}:</span>
<span class="font-mono" x-text="task.acquisition_json.camera_url"></span></p>
<p><span class="text-[var(--text-secondary)]">{{ _('Sorgente') }}:</span>
<span class="font-mono" x-text="task.acquisition_json.camera_source"></span></p>
<p><span class="text-[var(--text-secondary)]">{{ _('Modo di analisi') }}:</span>
<span x-text="task.acquisition_json.modo === 'in_linea' ? {{ _('Analisi in linea')|tojson_attr }} : {{ _('Analisi impulsiva')|tojson_attr }}"></span></p>
</div>
</template>
<p x-show="!(task.acquisition_json && task.acquisition_json.camera_url)" class="text-[var(--text-muted)]">
{{ _("Camera della stazione dell'operatore") }}
</p>
</div>
</template>
<!-- Modifica: servizio, sorgente, modo (salvati con «Salva» del task) -->
<template x-if="editingTask === task.id">
<div class="space-y-3"
x-data="taskAcquisition(() => editTaskData.acquisition, {{ {'genericError': _('Errore durante il caricamento delle sorgenti')}|tojson_attr }})">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="tmf-label text-xs">{{ _('Servizio di acquisizione') }}</label>
<select class="tmf-input text-xs" x-model="editTaskData.acquisition.camera_url" @change="onServizio()">
<option value="">{{ _("Camera della stazione dell'operatore") }}</option>
<template x-for="sv in serviziConCorrente()" :key="sv.camera_url">
<option :value="sv.camera_url"
x-text="sv.camera_url + (sv.stazioni.length ? ' (' + sv.stazioni.join(', ') + ')' : '')"></option>
</template>
</select>
</div>
<div x-show="editTaskData.acquisition.camera_url">
<label class="tmf-label text-xs">{{ _('Sorgente') }}</label>
<select class="tmf-input text-xs" x-model="editTaskData.acquisition.camera_source" :disabled="loading">
<option value="">{{ _('Scegli la sorgente') }}</option>
<template x-for="src in sourcesConCorrente()" :key="src.name">
<option :value="src.name" x-text="src.label || src.name"></option>
</template>
</select>
</div>
</div>
<p x-show="error" class="text-xs text-red-500" x-text="error"></p>
<div x-show="editTaskData.acquisition.camera_url" class="flex flex-wrap items-center gap-4 text-xs">
<label class="flex items-center gap-2">
<input type="radio" :name="'acq-modo-' + task.id" value="impulsiva" x-model="editTaskData.acquisition.modo">
{{ _('Analisi impulsiva') }}
</label>
<label class="flex items-center gap-2">
<input type="radio" :name="'acq-modo-' + task.id" value="in_linea" x-model="editTaskData.acquisition.modo">
{{ _('Analisi in linea') }}
</label>
</div>
</div>
</template>
</div>
</template>
<!-- ========== Ricetta visione (#46): solo camera_measure / dxf_compare ========== -->
<template x-if="task.task_type === 'camera_measure' || task.task_type === 'dxf_compare'">
<div class="p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]"
@@ -1335,6 +1400,7 @@
<script src="{{ url_for('static', filename='js/vision_recipe.js') }}"></script>
<script src="{{ url_for('static', filename='js/station_calibration.js') }}"></script>
<script src="{{ url_for('static', filename='js/dxf_recipe_wizard.js') }}"></script>
<script src="{{ url_for('static', filename='js/task_acquisition.js') }}"></script>{# #97 #}
<script src="{{ url_for('static', filename='vendor/pdf-3.11.174.min.js') }}"></script>
<script>if(typeof pdfjsLib!=='undefined')pdfjsLib.GlobalWorkerOptions.workerSrc='{{ url_for('static', filename='vendor/pdf.worker-3.11.174.min.js') }}';</script>
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
@@ -1389,7 +1455,7 @@ function taskEditor() {
// ---- Task CRUD state ----
editingTask: null,
editTaskData: { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [] },
editTaskData: { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [], acquisition: { camera_url: '', camera_source: '', modo: 'impulsiva' } }, // #97
newTask: { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [] },
MAX_NOTE_FIELDS: 20, // #55: limite campi per nota (stesso del backend)
_fieldUid: 0,
@@ -1575,6 +1641,18 @@ function taskEditor() {
this.newTask = { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [] };
},
// #97: acquisition_json del task; null = camera della stazione (voce vuota o tipo senza camera)
_acquisitionPayload(obj) {
const a = obj.acquisition || {};
const usesCamera = obj.task_type === 'camera_measure' || obj.task_type === 'dxf_compare';
if (!usesCamera || !a.camera_url) return { ok: true, value: null };
if (!a.camera_source) {
this.errorMessage = {{ _("Scegli la sorgente della camera")|tojson }};
return { ok: false };
}
return { ok: true, value: { camera_url: a.camera_url, camera_source: a.camera_source, modo: a.modo === 'in_linea' ? 'in_linea' : 'impulsiva' } };
},
// ---- #53 #55: ciclo di misura e campi della nota ----
// #53: default di "nel ciclo" per tipo (stessa regola del backend)
@@ -1785,7 +1863,13 @@ function taskEditor() {
in_loop: task.in_loop != null ? !!task.in_loop : this.loopDefault(task.task_type || 'note'),
in_loop_touched: false,
at_close: !!task.at_close,
fields: this._fieldsToState(task.fields)
fields: this._fieldsToState(task.fields),
// #97: camera e modo di analisi del task (acquisition_json), vuoti = camera della stazione
acquisition: {
camera_url: (task.acquisition_json && task.acquisition_json.camera_url) || '',
camera_source: (task.acquisition_json && task.acquisition_json.camera_source) || '',
modo: (task.acquisition_json && task.acquisition_json.modo) || 'impulsiva'
}
};
// Ensure expanded
this.expandedTask = task.id;
@@ -1793,7 +1877,7 @@ function taskEditor() {
cancelEditTask() {
this.editingTask = null;
this.editTaskData = { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [] };
this.editTaskData = { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [], acquisition: { camera_url: '', camera_source: '', modo: 'impulsiva' } }; // #97
},
async updateTask(taskId) {
@@ -1801,6 +1885,8 @@ function taskEditor() {
this.errorMessage = '';
const fp = this._fieldsPayload(this.editTaskData); // #55
if (!fp.ok) return;
const acq = this._acquisitionPayload(this.editTaskData); // #97
if (!acq.ok) return;
this.saving = true;
try {
@@ -1818,7 +1904,8 @@ function taskEditor() {
// #53 #55: ciclo di misura, compilazione a fine produzione, campi della nota
in_loop: !!this.editTaskData.in_loop,
at_close: this.editTaskData.task_type === 'note' && !!this.editTaskData.at_close,
fields: fp.fields
fields: fp.fields,
acquisition_json: acq.value // #97
})
});
@@ -534,9 +534,10 @@
<div x-show="isVisionTask" x-cloak
x-data="visionExecute()"
x-init="ctx = () => ({ lot: lotNumber, serial: serialNumber, runId: productionRun ? productionRun.id : null })"
class="flex-1 overflow-auto p-3 flex flex-col lg:flex-row gap-3">
class="flex-1 min-h-0 overflow-hidden p-3 flex flex-col lg:flex-row gap-3">
{# #96: il pannello non scorre mai (overflow-hidden, min-h-0): l'immagine sta in fit nello spazio che resta #}
<div class="flex-1 min-w-0 flex flex-col gap-2">
<div class="flex-1 min-w-0 min-h-0 flex flex-col gap-2">
<div class="flex items-center gap-2 flex-wrap">
{# On a tablet this opens the camera; on a PC it picks a file. «Riprova» reuses the same input. #}
<label class="btn btn-primary text-sm gap-1.5 cursor-pointer" :class="busy ? 'opacity-50 pointer-events-none' : ''">
@@ -545,12 +546,29 @@
@change="onPick($event)" :disabled="busy">
</label>
{% if camera_enabled %}
{# #72: frame dalla camera della stazione, stessa strada di acquire() #}
{# #97: camera del task (o della stazione). Impulsiva: «Acquisisci» rifa grab+check; in linea: «Pausa»/«Riprendi» sul ciclo #}
{% if camera_mode == 'in_linea' %}
<button type="button" class="btn btn-secondary text-sm gap-1.5" @click="togglePause()"
:class="registering ? 'opacity-50 pointer-events-none' : ''"
:disabled="registering">
<span x-text="(paused || saved) ? '{{ _('Riprendi') }}' : '{{ _('Pausa') }}'"></span>
</button>
{% else %}
{# #72: frame dalla camera, stessa strada di acquire(); #97: ora grab + analisi senza salvare #}
<button type="button" class="btn btn-secondary text-sm gap-1.5" @click="grab()"
:class="(busy || grabbing) ? 'opacity-50 pointer-events-none' : ''"
:disabled="busy || grabbing">
<span>{{ _('Acquisisci dalla camera') }}</span>
<span>{{ _('Acquisisci') }}</span>
</button>
{% endif %}
{# #97: salva l'ULTIMO frame mostrato (execute); fino ad allora e' solo un'anteprima #}
<button type="button" class="btn btn-primary text-sm gap-1.5" @click="record()"
:class="canRecord ? '' : 'opacity-50 pointer-events-none'"
:disabled="!canRecord">
<span>{{ _('Registra') }}</span>
</button>
<span x-show="preview && !saved" x-cloak
class="inline-block px-2 py-0.5 rounded text-xs font-bold bg-amber-50 dark:bg-amber-900/20 border border-amber-300 dark:border-amber-700">{{ _('Anteprima, non registrata') }}</span>
<span x-show="grabbing" class="text-sm text-[var(--text-muted)]">{{ _('Acquisizione in corso...') }}</span>
{% endif %}
<span x-show="busy" class="text-sm text-[var(--text-muted)]">{{ _('Elaborazione in corso...') }}</span>
@@ -558,16 +576,18 @@
<div x-show="error" class="p-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-sm text-measure-fail" x-text="error"></div>
<div x-show="shownImage" class="relative border border-[var(--border-color)] rounded-lg overflow-hidden bg-black/5">
<img x-ref="img" :src="shownImage" @load="drawOverlay()" alt="" class="block w-full h-auto">
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full pointer-events-none"></canvas>
{# #96: riquadro flex-1 min-h-0; img e canvas object-contain sovrapposti (il canvas ha la stessa risoluzione naturale dell'immagine, quindi l'overlay resta allineato) #}
<div x-show="shownImage" class="relative flex-1 min-h-0 border border-[var(--border-color)] rounded-lg overflow-hidden bg-black/5">
<img x-ref="img" :src="shownImage" @load="drawOverlay()" alt="" class="absolute inset-0 w-full h-full object-contain">
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full object-contain pointer-events-none"></canvas>
</div>
<div x-show="!shownImage" class="text-sm text-[var(--text-muted)]">
{{ _('Nessuna immagine acquisita: premi «Acquisisci immagine».') }}
</div>
</div>
<div class="lg:w-80 shrink-0 flex flex-col gap-2" x-show="result">
{# #96: colonna esito con scorrimento proprio (solo con molte quote); sotto lg max 40% dell'altezza #}
<div class="lg:w-80 shrink-0 min-h-0 overflow-y-auto max-h-[40%] lg:max-h-none flex flex-col gap-2" x-show="result">
<div class="text-sm font-bold text-[var(--text-primary)]">
{{ _('Esito') }}: <span x-text="(result && result.outcome) || '-'"></span>
</div>
@@ -1146,6 +1166,9 @@
window.__visionCfg = {
executeUrl: '{{ url_for("measure.api_vision_execute") }}',
grabUrl: '{{ url_for("measure.api_vision_grab") }}',
checkUrl: '{{ url_for("measure.api_vision_check") }}', {# #97: analisi senza salvare #}
cameraAvailable: {{ (camera_enabled|default(false))|tojson }},
modo: {{ (camera_mode|default('impulsiva'))|tojson }},
imageUrlTpl: '{{ url_for("measure.api_vision_result_image", result_id=0) }}',
taskId: {{ task.id|tojson }},
taskType: {{ (task.task_type or '')|tojson }},
@@ -0,0 +1,140 @@
"""gitea:Adriano/TieMeasureFlow#97 - camera e modo di analisi nel task visione (lato Maker).
api_client e requests sono finti: nessun servizio camera vero.
"""
import json
import re
from unittest.mock import MagicMock, patch
import requests
from tests.conftest import api_get_router
ST1 = {"id": 4, "code": "ST-4", "name": "Banco", "camera_url": "http://acq:8765/", "camera_source": "camera:1"}
ST2 = {"id": 5, "code": "ST-5", "name": "Linea", "camera_url": "http://acq:8765", "camera_source": "camera:2"}
ST3 = {"id": 6, "code": "ST-6", "name": "Altra", "camera_url": "http://altro:9000", "camera_source": "x"}
NO_CAMERA = {"id": 7, "code": "ST-7", "name": "Senza", "camera_url": None, "camera_source": None}
SOURCES = {"sources": [
{"name": "camera:601020", "kind": "camera", "label": "Basler 601020"},
{"name": "pezzo.png", "kind": "image", "label": "immagine - pezzo.png"},
]}
def _stations(mock_api_client, stations=(ST1, ST2, ST3, NO_CAMERA)):
mock_api_client.get.side_effect = api_get_router({"/api/stations": list(stations)})
def _fake(status=200, body=None):
resp = MagicMock(status_code=status, text="")
if body is None:
resp.json.side_effect = ValueError("no json")
else:
resp.json.return_value = body
return resp
class TestCameraServizi:
def test_distinct_urls_with_stations(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
resp = logged_in_client.get("/maker/api/camera/servizi")
assert resp.status_code == 200
assert resp.get_json() == {"servizi": [
{"camera_url": "http://acq:8765", "stazioni": ["ST-4 - Banco", "ST-5 - Linea"]},
{"camera_url": "http://altro:9000", "stazioni": ["ST-6 - Altra"]},
]}
def test_no_stations_with_camera(self, logged_in_client, mock_api_client):
_stations(mock_api_client, [NO_CAMERA])
assert logged_in_client.get("/maker/api/camera/servizi").get_json() == {"servizi": []}
class TestCameraSorgenti:
def test_lists_sources(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
with patch("blueprints.maker.http_requests.get", return_value=_fake(200, SOURCES)) as get:
resp = logged_in_client.get("/maker/api/camera/sorgenti?url=http://acq:8765/")
assert resp.status_code == 200
assert resp.get_json() == SOURCES
args, kwargs = get.call_args
assert args[0] == "http://acq:8765/camera/sources"
assert kwargs["timeout"] == 15
def test_url_not_a_station_service_is_400(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
with patch("blueprints.maker.http_requests.get") as get:
resp = logged_in_client.get("/maker/api/camera/sorgenti?url=http://evil:1")
assert resp.status_code == 400
get.assert_not_called()
def test_missing_url_is_400(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
with patch("blueprints.maker.http_requests.get") as get:
assert logged_in_client.get("/maker/api/camera/sorgenti").status_code == 400
get.assert_not_called()
def test_service_down_is_502(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
with patch("blueprints.maker.http_requests.get",
side_effect=requests.ConnectionError("refused")):
resp = logged_in_client.get("/maker/api/camera/sorgenti?url=http://acq:8765")
assert resp.status_code == 502
assert "refused" in resp.get_json()["detail"]
def test_service_error_status_is_502(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
with patch("blueprints.maker.http_requests.get", return_value=_fake(500, {"detail": "x"})):
resp = logged_in_client.get("/maker/api/camera/sorgenti?url=http://acq:8765")
assert resp.status_code == 502
class TestTaskProxyForwardsAcquisition:
def test_put_forwards_acquisition_json(self, logged_in_client, mock_api_client):
acq = {"camera_url": "http://acq:8765", "camera_source": "camera:1", "modo": "in_linea"}
mock_api_client.put.return_value = {"id": 11, "title": "T", "acquisition_json": acq}
resp = logged_in_client.put("/maker/api/tasks/11", json={"title": "T", "acquisition_json": acq})
assert resp.status_code == 200
assert mock_api_client.put.call_args.kwargs["data"]["acquisition_json"] == acq
def test_put_forwards_null(self, logged_in_client, mock_api_client):
mock_api_client.put.return_value = {"id": 11, "title": "T", "acquisition_json": None}
logged_in_client.put("/maker/api/tasks/11", json={"title": "T", "acquisition_json": None})
assert mock_api_client.put.call_args.kwargs["data"]["acquisition_json"] is None
class TestTaskEditorAcquisitionUi:
def _html(self, client, mock_api_client, task_type="camera_measure"):
mock_api_client.get.side_effect = api_get_router({
"/api/recipes/3": {"id": 3, "code": "R", "name": "R"},
"/api/recipes/3/tasks": [{
"id": 11, "title": "T", "task_type": task_type, "subtasks": [],
"file_path": None, "order_index": 1,
"acquisition_json": {"camera_url": "http://acq:8765", "camera_source": "camera:1",
"modo": "in_linea"},
}],
})
return client.get("/maker/recipes/3/tasks").get_data(as_text=True)
def test_section_and_fields(self, logged_in_client, mock_api_client):
html = self._html(logged_in_client, mock_api_client)
assert "taskAcquisition(" in html
assert "js/task_acquisition.js" in html
assert "Servizio di acquisizione" in html
assert "Sorgente" in html
assert "Analisi impulsiva" in html and "Analisi in linea" in html
assert 'value="impulsiva"' in html and 'value="in_linea"' in html
assert "Camera della stazione dell&#39;operatore" in html or \
"Camera della stazione dell'operatore" in html
def test_payload_carries_acquisition_json(self, logged_in_client, mock_api_client):
html = self._html(logged_in_client, mock_api_client)
assert "acquisition_json: acq.value" in html
assert "_acquisitionPayload(this.editTaskData)" in html
# il task salvato riempie lo stato di modifica
assert "task.acquisition_json.camera_url" in html
def test_static_js_endpoints(self):
import os
path = os.path.join(os.path.dirname(__file__), "..", "static", "js", "task_acquisition.js")
src = open(path, encoding="utf-8").read()
assert "/maker/api/camera/servizi" in src
assert "/maker/api/camera/sorgenti?url=" in src
@@ -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