Compare commits

...

4 Commits

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

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

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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 17:31:38 +02:00
10 changed files with 809 additions and 38 deletions
+95 -7
View File
@@ -268,6 +268,22 @@ PAGINA = r"""<!doctype html>
</div> </div>
</section> </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"> <section class="scheda" id="sezioneSalvate">
<h2>Calibrazioni salvate</h2> <h2>Calibrazioni salvate</h2>
<div id="stato" class="stato">-</div> <div id="stato" class="stato">-</div>
@@ -286,6 +302,7 @@ var ultimo = null; // ultimo calcolo di calibrazione, per «Salva»
// numero all'italiana (virgola decimale); cifre = decimali fissi, omesso = fino a 4 // numero all'italiana (virgola decimale); cifre = decimali fissi, omesso = fino a 4
function fmt(v, cifre) { function fmt(v, cifre) {
if (v === null || v === undefined || v === "") return "-"; // #95: campi null (rms_px, square_mm della manuale) = «-», non 0
var n = Number(v); var n = Number(v);
if (!isFinite(n)) return "-"; if (!isFinite(n)) return "-";
var o = cifre === undefined ? { maximumFractionDigits: 4 } : { minimumFractionDigits: cifre, maximumFractionDigits: cifre }; var o = cifre === undefined ? { maximumFractionDigits: 4 } : { minimumFractionDigits: cifre, maximumFractionDigits: cifre };
@@ -459,7 +476,7 @@ function cambioSorgente(salva) {
// ---- Anteprima (#87) // ---- Anteprima (#87)
// #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 // #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, sorgenteBloccata: null, urlBlocco: null, seqBlocco: 0, lato: 1280, calAttiva: null, bloccoCal: false, nitMax: 0, ris: null }; var A = { attiva: false, gen: 0, inVolo: false, url: null, urlCal: null, seqCal: 0, bloccata: false, bloccando: 0, sorgenteBloccata: null, urlBlocco: null, seqBlocco: 0, lato: 1280, calAttiva: null, bloccoCal: false, nitMax: 0, ris: null };
function azzeraIndicatori() { function azzeraIndicatori() {
A.nitMax = 0; A.nitMax = 0;
@@ -494,14 +511,14 @@ function mostraInTabCal(url) {
} }
// #92: riquadro «Calibrata»: l'immagine raddrizzata oppure il motivo per cui non c'e' (404/409 del servizio) // #92: riquadro «Calibrata»: l'immagine raddrizzata oppure il motivo per cui non c'e' (404/409 del servizio)
function mostraCalibrata(blob, nome) { function mostraCalibrata(blob, nome, soloScala) {
var vecchio = A.urlCal; var vecchio = A.urlCal;
A.urlCal = URL.createObjectURL(blob); A.urlCal = URL.createObjectURL(blob);
$("immagineCal").src = A.urlCal; $("immagineCal").src = A.urlCal;
if (vecchio) URL.revokeObjectURL(vecchio); if (vecchio) URL.revokeObjectURL(vecchio);
$("corniceCal").hidden = false; $("corniceCal").hidden = false;
$("vuotoCal").hidden = true; $("vuotoCal").hidden = true;
$("nomeCalibrata").textContent = nome ? "«" + nome + "»" : ""; $("nomeCalibrata").textContent = (nome ? "«" + nome + "»" : "") + (soloScala ? " — solo scala, nessuna correzione ottica" : ""); // #95: senza mappa il frame e' grezzo
} }
function messaggioCalibrata(testo) { function messaggioCalibrata(testo) {
$("corniceCal").hidden = true; $("corniceCal").hidden = true;
@@ -544,7 +561,7 @@ async function aggiornaCalibrata(lato) {
try { nome = decodeURIComponent(r.headers.get("X-Calibrazione") || ""); } catch (e) { /* intestazione non decodificabile: nessun nome */ } try { nome = decodeURIComponent(r.headers.get("X-Calibrazione") || ""); } catch (e) { /* intestazione non decodificabile: nessun nome */ }
var blob = await r.blob(); var blob = await r.blob();
if (n !== A.seqCal) return; if (n !== A.seqCal) return;
mostraCalibrata(blob, nome); mostraCalibrata(blob, nome, r.headers.get("X-Raddrizzato") === "no"); // #95
} catch (e) { } catch (e) {
if (n === A.seqCal) messaggioCalibrata("Servizio non raggiungibile."); if (n === A.seqCal) messaggioCalibrata("Servizio non raggiungibile.");
} }
@@ -612,6 +629,8 @@ async function unaAnteprima(lato) {
async function ciclo(g) { async function ciclo(g) {
while (A.inVolo) await pausa(50); while (A.inVolo) await pausa(50);
while (A.attiva && g === A.gen) { while (A.attiva && g === A.gen) {
while (A.bloccando) await pausa(20); // #94: durante «Blocca immagine» nessuna nuova anteprima
if (!A.attiva || g !== A.gen) return;
var t0 = Date.now(); var t0 = Date.now();
var esito = await unaAnteprima(1280); var esito = await unaAnteprima(1280);
if (g !== A.gen) return; if (g !== A.gen) return;
@@ -640,6 +659,7 @@ async function scatta() {
$("scatta").disabled = $("scattaCal").disabled = true; // #94: anche il «Scatta» della tab Calibrazione $("scatta").disabled = $("scattaCal").disabled = true; // #94: anche il «Scatta» della tab Calibrazione
dire("msgAnteprima", "Scatto in corso...", ""); dire("msgAnteprima", "Scatto in corso...", "");
while (A.inVolo) await pausa(50); while (A.inVolo) await pausa(50);
while (A.bloccando) await pausa(20); // #94: niente scatto mentre si blocca l'immagine
await unaAnteprima(4096); await unaAnteprima(4096);
$("scatta").disabled = false; $("scatta").disabled = false;
$("scattaCal").disabled = A.bloccata; // #94: con l'immagine bloccata resta spento $("scattaCal").disabled = A.bloccata; // #94: con l'immagine bloccata resta spento
@@ -673,11 +693,17 @@ $("bloccaImg").onchange = async function () {
} }
A.bloccata = true; // subito: il riquadro smette di seguire il ciclo A.bloccata = true; // subito: il riquadro smette di seguire il ciclo
A.sorgenteBloccata = sorgente; A.sorgenteBloccata = sorgente;
A.urlBlocco = A.url; A.urlBlocco = null; // #94: lo si fissa dopo l'attesa, sul frame davvero mostrato
var n = ++A.seqBlocco; var n = ++A.seqBlocco;
$("scattaCal").disabled = true; $("scattaCal").disabled = true;
dire("msgBlocco", "Blocco in corso...", ""); dire("msgBlocco", "Blocco in corso...", "");
A.bloccando++; // #94: il ciclo e «Scatta» non chiedono nuove anteprime finche' la copia non e' fatta
try { 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 }) }); 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 (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 */ }); if (r.ok) fetch("/camera/blocca?source=" + encodeURIComponent(sorgente), { method: "DELETE" }).catch(function () { /* niente da fare */ });
@@ -688,6 +714,8 @@ $("bloccaImg").onchange = async function () {
dire("msgBlocco", "Immagine bloccata " + b.larghezza + "×" + b.altezza + ": «Calcola» usa questa.", "ok"); dire("msgBlocco", "Immagine bloccata " + b.larghezza + "×" + b.altezza + ": «Calcola» usa questa.", "ok");
} catch (e) { } catch (e) {
if (n === A.seqBlocco) { sbloccaLocale(); dire("msgBlocco", "Servizio non raggiungibile.", "errore"); } if (n === A.seqBlocco) { sbloccaLocale(); dire("msgBlocco", "Servizio non raggiungibile.", "errore"); }
} finally {
A.bloccando--; // #94: anche su errore o uscita anticipata
} }
}; };
@@ -951,6 +979,11 @@ $("tabCal").onclick = function () { scegliTab("cal", true); };
// ---- Calibrazione (logica di #83 e #84; #87: lato precompilato, data italiana, conferma in pagina) // ---- Calibrazione (logica di #83 e #84; #87: lato precompilato, data italiana, conferma in pagina)
function righe(d) { function righe(d) {
if (d.metodo === "manuale") return [ // #95: solo scala impostata a mano, niente griglia, residui o lato dello scacco
["Scala", fmt(d.px_per_mm, 4) + " px/mm (" + fmt(d.mm_per_px, 5) + " mm/px)"],
["Metodo", "manuale (mm/px impostato a mano, nessuna correzione della distorsione)"],
["Immagine", fmt(d.image_width, 0) + " x " + fmt(d.image_height, 0) + " px"]
];
return [ return [
["Scala", fmt(d.px_per_mm, 4) + " px/mm (" + fmt(d.mm_per_px, 5) + " mm/px)"], ["Scala", fmt(d.px_per_mm, 4) + " px/mm (" + fmt(d.mm_per_px, 5) + " mm/px)"],
["Metodo", d.metodo !== "densa" ? "planare (senza correzione della distorsione)" : "densa (immagine raddrizzata)"], ["Metodo", d.metodo !== "densa" ? "planare (senza correzione della distorsione)" : "densa (immagine raddrizzata)"],
@@ -1007,6 +1040,7 @@ async function aggiornaStato() {
var stato = $("stato"); var stato = $("stato");
annullaConferma(); annullaConferma();
$("calSorgente").textContent = sorgente ? $("sorgente").options[$("sorgente").selectedIndex].textContent : "-"; // #91 $("calSorgente").textContent = sorgente ? $("sorgente").options[$("sorgente").selectedIndex].textContent : "-"; // #91
statoManuale(); // #95: «Salva» della calibrazione manuale spento senza sorgente
if (!sorgente) { stato.textContent = "-"; $("statoSorgente").textContent = "-"; caricaCalibrazioni(); return; } if (!sorgente) { stato.textContent = "-"; $("statoSorgente").textContent = "-"; caricaCalibrazioni(); return; }
try { try {
var r = await fetch("/camera/calibration?source=" + encodeURIComponent(sorgente)); var r = await fetch("/camera/calibration?source=" + encodeURIComponent(sorgente));
@@ -1032,7 +1066,7 @@ async function aggiornaStato() {
stato.appendChild(t); stato.appendChild(t);
stato.appendChild(quando); stato.appendChild(quando);
$("statoSorgente").textContent = "calibrata: " + (d.nome || "(senza nome)") + " · " + fmt(d.px_per_mm, 2) + " px/mm"; // #92 $("statoSorgente").textContent = "calibrata: " + (d.nome || "(senza nome)") + " · " + fmt(d.px_per_mm, 2) + " px/mm"; // #92
if (d.square_mm) $("quadrato").value = fmtCasella(d.square_mm); // #87: parte dal lato gia' usato if (Number(d.square_mm) > 0) $("quadrato").value = fmtCasella(d.square_mm); // #87: parte dal lato gia' usato
} }
} catch (e) { } catch (e) {
stato.textContent = "Servizio non raggiungibile."; stato.textContent = "Servizio non raggiungibile.";
@@ -1103,7 +1137,7 @@ function disegnaElenco() {
riga2.textContent = [ riga2.textContent = [
c.image_width && c.image_height ? fmt(c.image_width, 0) + "×" + fmt(c.image_height, 0) : "-", c.image_width && c.image_height ? fmt(c.image_width, 0) + "×" + fmt(c.image_height, 0) : "-",
fmt(c.px_per_mm, 3) + " px/mm", fmt(c.px_per_mm, 3) + " px/mm",
c.metodo === "densa" ? "densa" : "planare", c.metodo === "densa" || c.metodo === "manuale" ? c.metodo : "planare", // #95: «manuale» mostrato come tale
c.calibrated_at ? dataItaliana(c.calibrated_at) : "-", c.calibrated_at ? dataItaliana(c.calibrated_at) : "-",
descriviOrigine(c.origine) descriviOrigine(c.origine)
].join(" · "); ].join(" · ");
@@ -1301,6 +1335,60 @@ async function salvaNome() {
} }
} }
// ---- #95: calibrazione manuale (mm/px a mano)
var M = { nome: null, timer: null }; // secondo clic su «Sovrascrivi» entro 4 s
function statoManuale() {
var s = $("sorgente").value;
$("salvaManuale").disabled = !s;
$("notaManuale").textContent = s ? "" : "Scegliere una sorgente nella tab Acquisizione: serve per registrare la risoluzione dell'immagine.";
}
function aggiornaManuale() {
var v = leggiNumero($("mmPx").value);
$("pxMmCalc").textContent = v > 0 ? "= " + fmt(1 / v, 4) + " px/mm" : "= - px/mm";
}
function annullaSovrascriviManuale() {
clearTimeout(M.timer);
M.timer = null;
M.nome = null;
$("salvaManuale").textContent = "Salva";
}
async function salvaManuale() {
var sorgente = $("sorgente").value;
if (!sorgente) { dire("msgManuale", "Scegliere una sorgente nella tab Acquisizione.", "errore"); return; }
var mm = leggiNumero($("mmPx").value);
if (!(mm >= 0.00001 && mm <= 100)) { dire("msgManuale", "mm/px deve essere un numero positivo (da 0,00001 a 100).", "errore"); return; }
var nome = $("nomeManuale").value.trim();
if (!nome) { dire("msgManuale", "Scrivere un nome per la calibrazione.", "errore"); return; }
if (nome.length > 60) { dire("msgManuale", "Il nome può avere al massimo 60 caratteri.", "errore"); return; }
var sovrascrivi = M.timer !== null && M.nome === nome.toLowerCase(); // secondo clic sullo stesso nome
annullaSovrascriviManuale();
$("salvaManuale").disabled = true;
try {
var r = await fetch("/camera/calibrazioni/manuale", {
method: "POST", headers: JSON_H,
body: JSON.stringify({ source: sorgente, nome: nome, mm_per_px: mm, sovrascrivi: sovrascrivi }) // al servizio sempre il numero, mai il testo con la virgola
});
if (r.status === 409) {
M.nome = nome.toLowerCase();
$("salvaManuale").textContent = "Sovrascrivi «" + nome + "»? Clic ancora";
M.timer = setTimeout(annullaSovrascriviManuale, 4000);
dire("msgManuale", await dettaglio(r), "errore");
return;
}
if (!r.ok) { dire("msgManuale", await dettaglio(r), "errore"); return; }
dire("msgManuale", "Calibrazione «" + nome + "» salvata: per usarla «Carica» o CONFERMA.", "ok");
caricaCalibrazioni();
} catch (e) {
dire("msgManuale", "Servizio non raggiungibile.", "errore");
} finally {
statoManuale();
}
}
$("mmPx").oninput = aggiornaManuale;
$("salvaManuale").onclick = salvaManuale;
$("nomeManuale").onkeydown = function (e) { if (e.key === "Enter") salvaManuale(); };
statoManuale();
$("acquisisci").onclick = calcola; $("acquisisci").onclick = calcola;
$("salva").onclick = salvaNome; $("salva").onclick = salvaNome;
$("nomeCal").onkeydown = function (e) { if (e.key === "Enter") salvaNome(); }; $("nomeCal").onkeydown = function (e) { if (e.key === "Enter") salvaNome(); };
+51 -9
View File
@@ -29,6 +29,10 @@ Calibrazione a scacchiera (#83, logica in scripts/calibrazione.py).
calibrated_at, attiva_su}], attiva: nome|null} (dalla piu' recente) calibrated_at, attiva_su}], attiva: nome|null} (dalla piu' recente)
POST /camera/calibrazioni json {calcolo_id, nome, sovrascrivi=false} -> 201 POST /camera/calibrazioni json {calcolo_id, nome, sovrascrivi=false} -> 201
metadati. 404 calcolo scaduto, 400 nome non valido, 409 nome gia' usato metadati. 404 calcolo scaduto, 400 nome non valido, 409 nome gia' usato
POST /camera/calibrazioni/manuale json {source, nome, mm_per_px, sovrascrivi=false}
-> 201 metadati (#95: mm/px impostato a mano, metodo "manuale", nessuna
mappa; non la attiva). 400 mm/px fuori 0.00001..100 o nome non valido,
409 nome gia' usato; la risoluzione e' quella del frame tenuto o di un grab
DELETE /camera/calibrazioni?nome=X -> 204 (toglie anche la mappa e la scollega DELETE /camera/calibrazioni?nome=X -> 204 (toglie anche la mappa e la scollega
da ogni sorgente) | 404 da ogni sorgente) | 404
PUT /camera/calibrazione-attiva json {source, nome}: legge un frame dalla PUT /camera/calibrazione-attiva json {source, nome}: legge un frame dalla
@@ -84,6 +88,7 @@ from __future__ import annotations
import argparse import argparse
import logging import logging
import math # #95: mm/px finito nella calibrazione manuale
import os import os
import re # #92: nome sicuro per le immagini caricate import re # #92: nome sicuro per le immagini caricate
import sys import sys
@@ -440,6 +445,35 @@ def create_app(folder: Path | str | None = None,
except calibrazione.NomeGiaUsato as e: except calibrazione.NomeGiaUsato as e:
raise HTTPException(409, str(e)) raise HTTPException(409, str(e))
@app.post("/camera/calibrazioni/manuale", status_code=201)
def calibrazioni_manuale(corpo: dict = Body(default=None)):
"""#95: calibrazione con mm/px impostato a mano (nessuna scacchiera, nessuna mappa); NON la attiva."""
corpo = corpo if isinstance(corpo, dict) else {}
sorgente = _sorgente(corpo.get("source"))
mm_px = corpo.get("mm_per_px")
if (isinstance(mm_px, bool) or not isinstance(mm_px, (int, float))
or not math.isfinite(mm_px) or not 0.00001 <= mm_px <= 100):
raise HTTPException(400, "mm/px deve essere un numero positivo")
sovrascrivi = corpo.get("sovrascrivi", False)
if not isinstance(sovrascrivi, bool):
raise HTTPException(400, "«sovrascrivi» deve essere true o false")
try:
nome = calibrazione.pulisci_nome(corpo.get("nome"))
except calibrazione.NomeNonValido as e:
raise HTTPException(400, str(e))
with lock:
frame = grezzi.get(sorgente)
if frame is None: # nessun frame tenuto: un grab come /camera/grab (404/503/504)
frame = _grab(sorgente, 5000)
alt, larg = frame.shape[:2] # serve al controllo W×H di PUT /camera/calibrazione-attiva
dati = {"px_per_mm": 1.0 / mm_px, "mm_per_px": float(mm_px), "metodo": "manuale",
"image_width": larg, "image_height": alt, "origine": sorgente,
"calibrated_at": calibrazione.ora_iso(), "rms_px": None, "square_mm": None}
try:
return archivio.salva_nome(nome, dati, None, sovrascrivi)
except calibrazione.NomeGiaUsato as e:
raise HTTPException(409, str(e))
@app.delete("/camera/calibrazioni", status_code=204) @app.delete("/camera/calibrazioni", status_code=204)
def calibrazioni_elimina(nome: str = ""): def calibrazioni_elimina(nome: str = ""):
if not archivio.elimina_nome(nome): if not archivio.elimina_nome(nome):
@@ -563,7 +597,7 @@ def create_app(folder: Path | str | None = None,
@app.post("/camera/anteprima/calibrata") @app.post("/camera/anteprima/calibrata")
def camera_anteprima_calibrata(corpo: dict = Body(default=None)): def camera_anteprima_calibrata(corpo: dict = Body(default=None)):
"""#92: l'ultimo frame grezzo dell'anteprima, raddrizzato; NON legge un frame nuovo.""" """#92: l'ultimo frame grezzo dell'anteprima, raddrizzato; NON legge un frame nuovo. #95: senza mappa esce grezzo (X-Raddrizzato: no)."""
corpo = corpo if isinstance(corpo, dict) else {} corpo = corpo if isinstance(corpo, dict) else {}
nome = _sorgente(corpo.get("source")) nome = _sorgente(corpo.get("source"))
lato = corpo.get("lato_max", 1280) lato = corpo.get("lato_max", 1280)
@@ -577,14 +611,22 @@ def create_app(folder: Path | str | None = None,
if attiva is None: if attiva is None:
raise HTTPException(409, "nessuna calibrazione attiva su questa sorgente") raise HTTPException(409, "nessuna calibrazione attiva su questa sorgente")
mappa = archivio.leggi_mappa(nome) mappa = archivio.leggi_mappa(nome)
if mappa is None: # planare: ha solo scala, nessuna mappa da applicare if mappa is None: # #95: senza mappa (manuale o planare) solo scala: frame grezzo, nessuna correzione
raise HTTPException(409, f"la calibrazione «{attiva}» non ha una mappa di correzione (planare)") dati = archivio.leggi(nome) or {}
corretto = calibrazione.raddrizza(frame, mappa) cw, ch = dati.get("image_width"), dati.get("image_height")
if corretto is None: # risoluzione diversa da quella calibrata
ch, cw = mappa.shape[:2]
alt, larg = frame.shape[:2] alt, larg = frame.shape[:2]
raise HTTPException(409, f"la calibrazione «{attiva}» è per immagini {cw}×{ch}, " if isinstance(cw, int) and isinstance(ch, int) and (cw, ch) != (larg, alt):
f"il frame è {larg}×{alt}") raise HTTPException(409, f"la calibrazione «{attiva}» è per immagini {cw}×{ch}, "
f"il frame è {larg}×{alt}")
corretto, raddrizzato = frame, "no"
else:
corretto = calibrazione.raddrizza(frame, mappa)
if corretto is None: # risoluzione diversa da quella calibrata
ch, cw = mappa.shape[:2]
alt, larg = frame.shape[:2]
raise HTTPException(409, f"la calibrazione «{attiva}» è per immagini {cw}×{ch}, "
f"il frame è {larg}×{alt}")
raddrizzato = "si"
alt, larg = corretto.shape[:2] alt, larg = corretto.shape[:2]
if corretto.dtype != np.uint8: # il JPEG vuole 8 bit if corretto.dtype != np.uint8: # il JPEG vuole 8 bit
corretto = _grigio8(corretto) corretto = _grigio8(corretto)
@@ -596,7 +638,7 @@ def create_app(folder: Path | str | None = None,
if not ok: if not ok:
raise HTTPException(500, "codifica JPEG fallita") raise HTTPException(500, "codifica JPEG fallita")
return Response(buf.tobytes(), media_type="image/jpeg", headers={ return Response(buf.tobytes(), media_type="image/jpeg", headers={
"X-Raddrizzato": "si", "X-Larghezza": str(larg), "X-Altezza": str(alt), "X-Raddrizzato": raddrizzato, "X-Larghezza": str(larg), "X-Altezza": str(alt),
"X-Calibrazione": urllib.parse.quote(attiva, safe="")}) "X-Calibrazione": urllib.parse.quote(attiva, safe="")})
@app.get("/camera/parametri") @app.get("/camera/parametri")
+12
View File
@@ -142,3 +142,15 @@ def test_pagina_blocco_rotte_e_cambio_sorgente():
cambio = PAGINA.split("function cambioSorgente")[1].split("// ---- Anteprima")[0] cambio = PAGINA.split("function cambioSorgente")[1].split("// ---- Anteprima")[0]
assert "rilasciaBlocco()" in cambio assert "rilasciaBlocco()" in cambio
assert cambio.index("rilasciaBlocco()") < cambio.index("svuotaRiquadri()") 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)")
+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
+4 -3
View File
@@ -282,10 +282,11 @@ def test_calibrata_risoluzione_diversa_409(client):
assert r.json()["detail"] == "la calibrazione «Banco» è per immagini 640×480, il frame è 320×240" assert r.json()["detail"] == "la calibrazione «Banco» è per immagini 640×480, il frame è 320×240"
def test_calibrata_planare_senza_mappa_409(client): def test_calibrata_planare_senza_mappa_200_grezza(client):
# #95: senza mappa (planare) non e' piu' 409: esce il frame grezzo, X-Raddrizzato: no
_carica(client, PIANA, una_scacchiera()) _carica(client, PIANA, una_scacchiera())
_calibra_e_attiva(client, PIANA, "Piana", cols="9", rows="6") _calibra_e_attiva(client, PIANA, "Piana", cols="9", rows="6")
assert _anteprima(client, PIANA).status_code == 200 assert _anteprima(client, PIANA).status_code == 200
r = _calibrata(client, PIANA) r = _calibrata(client, PIANA)
assert r.status_code == 409 assert r.status_code == 200
assert r.json()["detail"] == "la calibrazione «Piana» non ha una mappa di correzione (planare)" assert r.headers["X-Raddrizzato"] == "no" and unquote(r.headers["X-Calibrazione"]) == "Piana"
+82 -2
View File
@@ -1,4 +1,5 @@
"""MeasurementTec blueprint - recipe selection and measurement execution.""" """MeasurementTec blueprint - recipe selection and measurement execution."""
import json
import urllib.parse import urllib.parse
import requests as http_requests import requests as http_requests
@@ -337,6 +338,14 @@ def task_execute(task_id: int):
measurement_interval_minutes = recipe_resp.get("measurement_interval_minutes") measurement_interval_minutes = recipe_resp.get("measurement_interval_minutes")
allow_manual_input = bool(recipe_resp.get("allow_manual_input", True)) allow_manual_input = bool(recipe_resp.get("allow_manual_input", True))
# #97: camera del task (acquisition_json) o della stazione; il modo di analisi
# (impulsiva / in_linea) viene dal task. Solo i task visione ne hanno bisogno.
camera = None
camera_mode = "impulsiva"
if task_resp.get("task_type") in ("camera_measure", "dxf_compare"):
camera = _task_camera(task_resp, _current_station()[0])
camera_mode = _task_mode(task_resp)
return render_template( return render_template(
"measure/task_execute.html", "measure/task_execute.html",
task=task_resp, task=task_resp,
@@ -345,7 +354,8 @@ def task_execute(task_id: int):
all_task_ids=all_task_ids, all_task_ids=all_task_ids,
measurement_interval_minutes=measurement_interval_minutes, measurement_interval_minutes=measurement_interval_minutes,
allow_manual_input=allow_manual_input, allow_manual_input=allow_manual_input,
camera_enabled=_station_camera(_current_station()[0]) is not None, camera_enabled=camera is not None,
camera_mode=camera_mode,
) )
@@ -1001,6 +1011,42 @@ def api_vision_execute():
return jsonify(resp), 200 return jsonify(resp), 200
# #97: analysis of a frame without saving anything (preview before «Registra»).
@measure_bp.route("/api/vision/check", methods=["POST"])
@login_required
@role_required("MeasurementTec")
def api_vision_check():
"""Proxy: run the task's VisionSuite recipe on the image, nothing is saved."""
image = request.files.get("image")
task_id = request.form.get("task_id")
if image is None or not task_id:
return jsonify({
"error": True,
"detail": _("Dati mancanti: task_id e image sono obbligatori"),
}), 400
form = {"task_id": task_id}
if request.form.get("production_run_id"):
form["production_run_id"] = request.form["production_run_id"]
resp = api_client.post(
"/api/vision/check",
data=form,
files={"image": (
image.filename or "capture.jpg",
image.read(),
image.mimetype or "application/octet-stream",
)},
)
if resp.get("error"):
status_code = resp.get("status_code", 500)
body = {"error": True, "detail": resp.get("detail", _("Errore nell'esecuzione"))}
if resp.get("session_expired"):
body["session_expired"] = True
return jsonify(body), status_code if status_code >= 400 else 500
return jsonify(resp), 200
def _station_camera(station_code: str | None) -> tuple[str, str] | None: def _station_camera(station_code: str | None) -> tuple[str, str] | None:
"""(camera_url, camera_source) of the station, None when it has no camera. """(camera_url, camera_source) of the station, None when it has no camera.
@@ -1019,6 +1065,31 @@ def _station_camera(station_code: str | None) -> tuple[str, str] | None:
return str(url), str(source) return str(url), str(source)
def _task_acquisition(task: dict | None) -> dict | None:
"""#97: acquisition_json of a task as a dict, None when absent or unreadable."""
acq = task.get("acquisition_json") if isinstance(task, dict) else None
if isinstance(acq, str):
try:
acq = json.loads(acq)
except ValueError:
return None
return acq if isinstance(acq, dict) else None
def _task_camera(task: dict | None, station_code: str | None) -> tuple[str, str] | None:
"""#97: camera of a task = its acquisition_json (url + source), else the station's."""
acq = _task_acquisition(task)
if acq and acq.get("camera_url") and acq.get("camera_source"):
return str(acq["camera_url"]), str(acq["camera_source"])
return _station_camera(station_code)
def _task_mode(task: dict | None) -> str:
"""#97: analysis mode of a task, «impulsiva» unless acquisition_json says «in_linea»."""
acq = _task_acquisition(task)
return "in_linea" if acq and acq.get("modo") == "in_linea" else "impulsiva"
# #72: the browser asks the station's camera service (vs-gui) for a frame; the # #72: the browser asks the station's camera service (vs-gui) for a frame; the
# service address is a server-side matter, so Flask relays and the PNG goes back as is. # service address is a server-side matter, so Flask relays and the PNG goes back as is.
@measure_bp.route("/api/vision/grab", methods=["POST"]) @measure_bp.route("/api/vision/grab", methods=["POST"])
@@ -1026,7 +1097,16 @@ def _station_camera(station_code: str | None) -> tuple[str, str] | None:
@role_required("MeasurementTec") @role_required("MeasurementTec")
def api_vision_grab(): def api_vision_grab():
"""Proxy: grab one frame from the current station's camera, answer a PNG.""" """Proxy: grab one frame from the current station's camera, answer a PNG."""
camera = _station_camera(_current_station()[0]) # #97: con task_id (form o json) la camera e' quella del task, altrimenti della stazione
payload = request.get_json(silent=True) or {}
task_id = request.form.get("task_id") or payload.get("task_id")
task = None
if task_id:
try:
task = api_client.get(f"/api/tasks/{int(task_id)}")
except (TypeError, ValueError):
task = None
camera = _task_camera(task, _current_station()[0])
if camera is None: if camera is None:
return jsonify({ return jsonify({
"error": True, "error": True,
@@ -11,6 +11,13 @@
* taskType, subtasks, labels. * taskType, subtasks, labels.
* Run context (lot, serial, production run) is read lazily through `ctx()`, * Run context (lot, serial, production run) is read lazily through `ctx()`,
* which the template binds to the parent task component. * which the template binds to the parent task component.
*
* #97: with a camera (cameraAvailable, the task's or the station's) the task opens
* on a frame already grabbed and analysed (/vision/check, nothing saved: «preview»).
* modo «impulsiva»: «Acquisisci» redoes grab+check. modo «in_linea»: continuous
* loop grab+check, one request at a time, standing still while paused / page hidden
* / leaving / saving. «Registra» saves the LAST frame shown through /vision/execute.
* Extra config: cameraAvailable, modo, checkUrl.
*/ */
function visionExecute() { function visionExecute() {
// Overlay colour per role (VisionSuite roles: reference, roi, found). // Overlay colour per role (VisionSuite roles: reference, roi, found).
@@ -24,6 +31,125 @@ function visionExecute() {
imageSrc: '', // object URL of the acquired image (shown under the overlay) imageSrc: '', // object URL of the acquired image (shown under the overlay)
ctx: () => ({}), // replaced by the template: {lot, serial, runId} ctx: () => ({}), // replaced by the template: {lot, serial, runId}
// #97: preview / loop state
preview: false, // result on screen is an analysis only, not saved
saved: false, // «Registra» done: the saved result replaces the preview
lastBlob: null, // last frame shown, the one «Registra» saves
paused: false, // operator paused the in-line loop
registering: false, // «Registra» in progress: the loop stands still
leaving: false, // page is being left: no more requests
looping: false, // an in-line loop is running
gen: 0, // bumped by «Registra»: answers of older requests are dropped
// #97: start on a frame already acquired and analysed; hook visibility and exit.
init() {
if (!this.hasCamera) return;
document.addEventListener('visibilitychange', () => { if (!document.hidden) this.kick(); });
const leave = () => { this.leaving = true; };
window.addEventListener('pagehide', leave);
window.addEventListener('beforeunload', leave);
// After x-init has bound ctx(): in-line starts the loop, impulsive grabs once.
this.$nextTick(() => { if (this.inLinea) this.loop(); else this.grab(); });
},
get hasCamera() { return !!(window.__visionCfg || {}).cameraAvailable; },
get inLinea() { return this.hasCamera && (window.__visionCfg || {}).modo === 'in_linea'; },
// «Registra» needs a frame on screen that is not saved yet
get canRecord() { return !!this.lastBlob && !this.saved && !this.registering; },
shouldRun() {
return this.inLinea && !this.paused && !this.saved && !this.registering
&& !this.leaving && !document.hidden;
},
// Page visible again: restart the loop unless the operator paused it.
kick() { if (this.shouldRun() && !this.looping) this.loop(); },
// In-line loop: one request at a time, at least 300 ms between two of them
// (longer after an error, so a dead camera is not hammered).
async loop() {
if (this.looping) return;
this.looping = true;
try {
while (this.shouldRun()) {
const ok = await this.grab();
await new Promise((r) => setTimeout(r, ok ? 300 : 2000));
}
} finally {
this.looping = false;
}
},
// «Pausa» / «Riprendi»; after «Registra» only «Riprendi» restarts the loop.
togglePause() {
if (this.paused || this.saved) {
this.paused = false;
this.saved = false;
this.loop();
} else {
this.paused = true;
}
},
// Analysis only (/vision/check): the frame becomes the preview, nothing is saved.
async analyze(blob) {
const cfg = window.__visionCfg || {};
if (!blob || !cfg.checkUrl) return false;
const gen = this.gen;
this.busy = true;
this.error = '';
try {
const c = this.ctx() || {};
const fd = new FormData();
fd.append('task_id', cfg.taskId);
fd.append('image', blob, blob.name || 'capture.jpg');
if (c.runId) fd.append('production_run_id', c.runId);
const csrf = document.querySelector('meta[name=csrf-token]')?.content || '';
const resp = await fetch(cfg.checkUrl, {
method: 'POST', headers: { 'X-CSRFToken': csrf }, body: fd,
});
const body = await resp.json().catch(() => ({}));
if (gen !== this.gen) return false; // «Registra» took over
if (!resp.ok || body.error) {
this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error';
return false;
}
if (this.imageSrc) URL.revokeObjectURL(this.imageSrc);
this.imageSrc = URL.createObjectURL(blob);
this.lastBlob = blob;
this.result = body;
this.preview = true;
this.saved = false;
this.$nextTick(() => this.drawOverlay());
return true;
} catch (e) {
if (gen === this.gen) this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
return false;
} finally {
if (gen === this.gen) this.busy = false;
}
},
// «Registra»: save the LAST frame shown with the usual /vision/execute.
async record() {
if (!this.canRecord) return;
const blob = this.lastBlob;
this.registering = true;
this.gen++; // drop whatever request is still in flight
this.busy = false;
this.grabbing = false;
try {
await this.acquire(blob);
if (!this.error && this.result && this.result.vision_result_id) {
this.preview = false;
this.saved = true;
}
} finally {
this.registering = false;
this.kick(); // in-line: stays still while saved, restarts if the save failed
}
},
// Entry point shared with the future agent camera. // Entry point shared with the future agent camera.
async acquire(blob) { async acquire(blob) {
if (!blob || this.busy) return; if (!blob || this.busy) return;
@@ -64,37 +190,52 @@ function visionExecute() {
// Station camera (#72): the Flask proxy answers a PNG, which goes through // Station camera (#72): the Flask proxy answers a PNG, which goes through
// acquire() like a picked file. Errors show as the execute ones do. // acquire() like a picked file. Errors show as the execute ones do.
// #97: sends task_id (the task's camera wins over the station's); with a camera
// the frame goes to the preview analysis. Returns true when the frame was analysed.
async grab() { async grab() {
const cfg = window.__visionCfg || {}; const cfg = window.__visionCfg || {};
if (this.busy || this.grabbing || !cfg.grabUrl) return; if (this.busy || this.grabbing || this.registering || !cfg.grabUrl) return false;
const gen = this.gen;
this.grabbing = true; this.grabbing = true;
this.error = ''; this.error = '';
let blob = null; let blob = null;
try { try {
const csrf = document.querySelector('meta[name=csrf-token]')?.content || ''; const csrf = document.querySelector('meta[name=csrf-token]')?.content || '';
const fd = new FormData();
fd.append('task_id', cfg.taskId);
const resp = await fetch(cfg.grabUrl, { const resp = await fetch(cfg.grabUrl, {
method: 'POST', headers: { 'X-CSRFToken': csrf }, method: 'POST', headers: { 'X-CSRFToken': csrf }, body: fd,
}); });
if (!resp.ok) { if (!resp.ok) {
const body = await resp.json().catch(() => ({})); const body = await resp.json().catch(() => ({}));
this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error'; if (gen === this.gen) this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error';
return; return false;
} }
blob = await resp.blob(); blob = await resp.blob();
} catch (e) { } catch (e) {
this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error'; if (gen === this.gen) this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
return; return false;
} finally { } finally {
this.grabbing = false; if (gen === this.gen) this.grabbing = false;
} }
if (blob) await this.acquire(blob); if (!blob || gen !== this.gen) return false;
if (this.hasCamera) return await this.analyze(blob);
await this.acquire(blob);
return !this.error;
}, },
// Input change: take the file and allow picking the same one again. // Input change: take the file and allow picking the same one again.
// #97: with a camera the file is a preview too (the loop pauses), «Registra» saves it.
onPick(ev) { onPick(ev) {
const f = ev.target.files && ev.target.files[0]; const f = ev.target.files && ev.target.files[0];
ev.target.value = ''; ev.target.value = '';
if (f) this.acquire(f); if (!f) return;
if (this.hasCamera) {
if (this.inLinea) this.paused = true;
this.analyze(f);
} else {
this.acquire(f);
}
}, },
// Image shown: the saved one from the server when available, else the local one. // Image shown: the saved one from the server when available, else the local one.
@@ -534,9 +534,10 @@
<div x-show="isVisionTask" x-cloak <div x-show="isVisionTask" x-cloak
x-data="visionExecute()" x-data="visionExecute()"
x-init="ctx = () => ({ lot: lotNumber, serial: serialNumber, runId: productionRun ? productionRun.id : null })" x-init="ctx = () => ({ lot: lotNumber, serial: serialNumber, runId: productionRun ? productionRun.id : null })"
class="flex-1 overflow-auto p-3 flex flex-col lg:flex-row gap-3"> class="flex-1 min-h-0 overflow-hidden p-3 flex flex-col lg:flex-row gap-3">
{# #96: il pannello non scorre mai (overflow-hidden, min-h-0): l'immagine sta in fit nello spazio che resta #}
<div class="flex-1 min-w-0 flex flex-col gap-2"> <div class="flex-1 min-w-0 min-h-0 flex flex-col gap-2">
<div class="flex items-center gap-2 flex-wrap"> <div class="flex items-center gap-2 flex-wrap">
{# On a tablet this opens the camera; on a PC it picks a file. «Riprova» reuses the same input. #} {# On a tablet this opens the camera; on a PC it picks a file. «Riprova» reuses the same input. #}
<label class="btn btn-primary text-sm gap-1.5 cursor-pointer" :class="busy ? 'opacity-50 pointer-events-none' : ''"> <label class="btn btn-primary text-sm gap-1.5 cursor-pointer" :class="busy ? 'opacity-50 pointer-events-none' : ''">
@@ -545,12 +546,29 @@
@change="onPick($event)" :disabled="busy"> @change="onPick($event)" :disabled="busy">
</label> </label>
{% if camera_enabled %} {% if camera_enabled %}
{# #72: frame dalla camera della stazione, stessa strada di acquire() #} {# #97: camera del task (o della stazione). Impulsiva: «Acquisisci» rifa grab+check; in linea: «Pausa»/«Riprendi» sul ciclo #}
{% if camera_mode == 'in_linea' %}
<button type="button" class="btn btn-secondary text-sm gap-1.5" @click="togglePause()"
:class="registering ? 'opacity-50 pointer-events-none' : ''"
:disabled="registering">
<span x-text="(paused || saved) ? '{{ _('Riprendi') }}' : '{{ _('Pausa') }}'"></span>
</button>
{% else %}
{# #72: frame dalla camera, stessa strada di acquire(); #97: ora grab + analisi senza salvare #}
<button type="button" class="btn btn-secondary text-sm gap-1.5" @click="grab()" <button type="button" class="btn btn-secondary text-sm gap-1.5" @click="grab()"
:class="(busy || grabbing) ? 'opacity-50 pointer-events-none' : ''" :class="(busy || grabbing) ? 'opacity-50 pointer-events-none' : ''"
:disabled="busy || grabbing"> :disabled="busy || grabbing">
<span>{{ _('Acquisisci dalla camera') }}</span> <span>{{ _('Acquisisci') }}</span>
</button> </button>
{% endif %}
{# #97: salva l'ULTIMO frame mostrato (execute); fino ad allora e' solo un'anteprima #}
<button type="button" class="btn btn-primary text-sm gap-1.5" @click="record()"
:class="canRecord ? '' : 'opacity-50 pointer-events-none'"
:disabled="!canRecord">
<span>{{ _('Registra') }}</span>
</button>
<span x-show="preview && !saved" x-cloak
class="inline-block px-2 py-0.5 rounded text-xs font-bold bg-amber-50 dark:bg-amber-900/20 border border-amber-300 dark:border-amber-700">{{ _('Anteprima, non registrata') }}</span>
<span x-show="grabbing" class="text-sm text-[var(--text-muted)]">{{ _('Acquisizione in corso...') }}</span> <span x-show="grabbing" class="text-sm text-[var(--text-muted)]">{{ _('Acquisizione in corso...') }}</span>
{% endif %} {% endif %}
<span x-show="busy" class="text-sm text-[var(--text-muted)]">{{ _('Elaborazione in corso...') }}</span> <span x-show="busy" class="text-sm text-[var(--text-muted)]">{{ _('Elaborazione in corso...') }}</span>
@@ -558,16 +576,18 @@
<div x-show="error" class="p-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-sm text-measure-fail" x-text="error"></div> <div x-show="error" class="p-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-sm text-measure-fail" x-text="error"></div>
<div x-show="shownImage" class="relative border border-[var(--border-color)] rounded-lg overflow-hidden bg-black/5"> {# #96: riquadro flex-1 min-h-0; img e canvas object-contain sovrapposti (il canvas ha la stessa risoluzione naturale dell'immagine, quindi l'overlay resta allineato) #}
<img x-ref="img" :src="shownImage" @load="drawOverlay()" alt="" class="block w-full h-auto"> <div x-show="shownImage" class="relative flex-1 min-h-0 border border-[var(--border-color)] rounded-lg overflow-hidden bg-black/5">
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full pointer-events-none"></canvas> <img x-ref="img" :src="shownImage" @load="drawOverlay()" alt="" class="absolute inset-0 w-full h-full object-contain">
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full object-contain pointer-events-none"></canvas>
</div> </div>
<div x-show="!shownImage" class="text-sm text-[var(--text-muted)]"> <div x-show="!shownImage" class="text-sm text-[var(--text-muted)]">
{{ _('Nessuna immagine acquisita: premi «Acquisisci immagine».') }} {{ _('Nessuna immagine acquisita: premi «Acquisisci immagine».') }}
</div> </div>
</div> </div>
<div class="lg:w-80 shrink-0 flex flex-col gap-2" x-show="result"> {# #96: colonna esito con scorrimento proprio (solo con molte quote); sotto lg max 40% dell'altezza #}
<div class="lg:w-80 shrink-0 min-h-0 overflow-y-auto max-h-[40%] lg:max-h-none flex flex-col gap-2" x-show="result">
<div class="text-sm font-bold text-[var(--text-primary)]"> <div class="text-sm font-bold text-[var(--text-primary)]">
{{ _('Esito') }}: <span x-text="(result && result.outcome) || '-'"></span> {{ _('Esito') }}: <span x-text="(result && result.outcome) || '-'"></span>
</div> </div>
@@ -1146,6 +1166,9 @@
window.__visionCfg = { window.__visionCfg = {
executeUrl: '{{ url_for("measure.api_vision_execute") }}', executeUrl: '{{ url_for("measure.api_vision_execute") }}',
grabUrl: '{{ url_for("measure.api_vision_grab") }}', grabUrl: '{{ url_for("measure.api_vision_grab") }}',
checkUrl: '{{ url_for("measure.api_vision_check") }}', {# #97: analisi senza salvare #}
cameraAvailable: {{ (camera_enabled|default(false))|tojson }},
modo: {{ (camera_mode|default('impulsiva'))|tojson }},
imageUrlTpl: '{{ url_for("measure.api_vision_result_image", result_id=0) }}', imageUrlTpl: '{{ url_for("measure.api_vision_result_image", result_id=0) }}',
taskId: {{ task.id|tojson }}, taskId: {{ task.id|tojson }},
taskType: {{ (task.task_type or '')|tojson }}, taskType: {{ (task.task_type or '')|tojson }},
@@ -0,0 +1,182 @@
"""gitea:Adriano/TieMeasureFlow#97 - operator: image already analysed on entry.
The backend (POST /api/vision/check, task.acquisition_json) is built elsewhere:
api_client and requests are mocked here with the contract's shapes.
"""
import io
import json
from unittest.mock import MagicMock, patch
from tests.conftest import api_get_router
from tests.test_measure_camera_grab import (
PNG, RECIPE, STATION, TASK, _fake, _station,
)
ACQ = {"camera_url": "http://acq-host:9000", "camera_source": "camera:601020", "modo": "in_linea"}
def _task_with(acq):
return {**TASK, "acquisition_json": acq}
# --- grab: the camera of the task, else the station's -------------------------
def test_grab_with_task_id_uses_task_camera(logged_in_client, mock_api_client, monkeypatch):
_station(mock_api_client, monkeypatch, STATION)
mock_api_client.get.side_effect = api_get_router({"/api/tasks/12": _task_with(ACQ)})
with patch("blueprints.measure.http_requests.post", return_value=_fake()) as post:
resp = logged_in_client.post("/measure/api/vision/grab", data={"task_id": "12"})
assert resp.status_code == 200 and resp.data == PNG
args, kwargs = post.call_args
assert args[0] == "http://acq-host:9000/camera/grab"
assert kwargs["json"]["source"] == "camera:601020"
mock_api_client.get_station_by_code.assert_not_called()
def test_grab_task_id_in_json_works_too(logged_in_client, mock_api_client, monkeypatch):
_station(mock_api_client, monkeypatch, STATION)
mock_api_client.get.side_effect = api_get_router({"/api/tasks/12": _task_with(ACQ)})
with patch("blueprints.measure.http_requests.post", return_value=_fake()) as post:
resp = logged_in_client.post("/measure/api/vision/grab", json={"task_id": 12})
assert resp.status_code == 200
assert post.call_args[0][0] == "http://acq-host:9000/camera/grab"
def test_grab_task_without_acquisition_uses_station(logged_in_client, mock_api_client, monkeypatch):
_station(mock_api_client, monkeypatch, STATION)
mock_api_client.get.side_effect = api_get_router({"/api/tasks/12": _task_with(None)})
with patch("blueprints.measure.http_requests.post", return_value=_fake()) as post:
resp = logged_in_client.post("/measure/api/vision/grab", data={"task_id": "12"})
assert resp.status_code == 200
args, kwargs = post.call_args
assert args[0] == "http://host.docker.internal:8765/camera/grab"
assert kwargs["json"]["source"] == "camera:folder"
def test_grab_task_camera_wins_even_if_station_has_none(logged_in_client, mock_api_client, monkeypatch):
_station(mock_api_client, monkeypatch, {**STATION, "camera_url": None})
mock_api_client.get.side_effect = api_get_router({"/api/tasks/12": _task_with(ACQ)})
with patch("blueprints.measure.http_requests.post", return_value=_fake()) as post:
resp = logged_in_client.post("/measure/api/vision/grab", data={"task_id": "12"})
assert resp.status_code == 200
assert post.call_args[0][0] == "http://acq-host:9000/camera/grab"
def test_grab_task_lookup_error_falls_back_to_station(logged_in_client, mock_api_client, monkeypatch):
_station(mock_api_client, monkeypatch, STATION)
mock_api_client.get.side_effect = api_get_router(
{"/api/tasks/12": {"error": True, "status_code": 404, "detail": "x"}})
with patch("blueprints.measure.http_requests.post", return_value=_fake()) as post:
resp = logged_in_client.post("/measure/api/vision/grab", data={"task_id": "12"})
assert resp.status_code == 200
assert post.call_args[0][0] == "http://host.docker.internal:8765/camera/grab"
def test_grab_acquisition_as_json_string(logged_in_client, mock_api_client, monkeypatch):
_station(mock_api_client, monkeypatch, {**STATION, "camera_url": None})
mock_api_client.get.side_effect = api_get_router(
{"/api/tasks/12": _task_with(json.dumps(ACQ))})
with patch("blueprints.measure.http_requests.post", return_value=_fake()) as post:
resp = logged_in_client.post("/measure/api/vision/grab", data={"task_id": "12"})
assert resp.status_code == 200
assert post.call_args[0][0] == "http://acq-host:9000/camera/grab"
# --- check proxy ---------------------------------------------------------------
CHECK_OK = {
"outcome": "ok", "overlay": [], "failures": [], "calibration": "ok",
"measurements": [{"subtask_id": 1, "value": 9.9, "pass_fail": "pass"}],
}
def test_check_proxy_forwards_multipart(logged_in_client, mock_api_client):
mock_api_client.post.return_value = CHECK_OK
resp = logged_in_client.post(
"/measure/api/vision/check",
data={
"task_id": "12", "production_run_id": "4",
"image": (io.BytesIO(b"\xff\xd8\xff fake"), "shot.jpg", "image/jpeg"),
},
content_type="multipart/form-data",
)
assert resp.status_code == 200
assert resp.get_json()["measurements"][0]["pass_fail"] == "pass"
args, kwargs = mock_api_client.post.call_args
assert args[0] == "/api/vision/check"
assert kwargs["data"] == {"task_id": "12", "production_run_id": "4"}
name, content, mime = kwargs["files"]["image"]
assert name == "shot.jpg" and mime == "image/jpeg" and content.startswith(b"\xff\xd8")
def test_check_proxy_without_run_leaves_it_out(logged_in_client, mock_api_client):
mock_api_client.post.return_value = CHECK_OK
logged_in_client.post(
"/measure/api/vision/check",
data={"task_id": "12", "image": (io.BytesIO(b"x"), "a.jpg", "image/jpeg")},
content_type="multipart/form-data",
)
assert mock_api_client.post.call_args[1]["data"] == {"task_id": "12"}
def test_check_proxy_requires_image(logged_in_client, mock_api_client):
resp = logged_in_client.post("/measure/api/vision/check", data={"task_id": "12"})
assert resp.status_code == 400
mock_api_client.post.assert_not_called()
def test_check_proxy_relays_backend_error(logged_in_client, mock_api_client):
mock_api_client.post.return_value = {"error": True, "status_code": 422, "detail": "nessun grafo"}
resp = logged_in_client.post(
"/measure/api/vision/check",
data={"task_id": "12", "image": (io.BytesIO(b"x"), "a.jpg", "image/jpeg")},
content_type="multipart/form-data",
)
assert resp.status_code == 422
assert resp.get_json()["detail"] == "nessun grafo"
def test_check_requires_login(client):
resp = client.post("/measure/api/vision/check")
assert resp.status_code in (302, 401)
# --- execute page: buttons and config -------------------------------------------
def _page(logged_in_client, mock_api_client, monkeypatch, station, task):
_station(mock_api_client, monkeypatch, station)
mock_api_client.get_station_recipes.return_value = {"items": [{"id": 3}]}
mock_api_client.get.side_effect = api_get_router({
"/api/tasks/12": task,
"/api/recipes/3/tasks": [{"id": 12, "order_index": 0}],
"/api/recipes/3": RECIPE,
})
return logged_in_client.get("/measure/execute/12").get_data(as_text=True)
def test_page_impulsive_has_acquire_and_record(logged_in_client, mock_api_client, monkeypatch):
html = _page(logged_in_client, mock_api_client, monkeypatch, STATION, TASK)
assert 'cameraAvailable: true' in html
assert 'modo: "impulsiva"' in html
assert "/measure/api/vision/check" in html
assert "grab()" in html and "record()" in html
assert "togglePause()" not in html
assert "Anteprima, non registrata" in html
def test_page_in_line_has_pause_and_record(logged_in_client, mock_api_client, monkeypatch):
html = _page(logged_in_client, mock_api_client, monkeypatch, STATION, _task_with(ACQ))
assert 'modo: "in_linea"' in html
assert "togglePause()" in html and "record()" in html
assert "grab()" not in html # the loop grabs by itself
def test_page_task_camera_enables_it_without_station_camera(logged_in_client, mock_api_client, monkeypatch):
html = _page(logged_in_client, mock_api_client, monkeypatch,
{**STATION, "camera_url": None}, _task_with({**ACQ, "modo": "impulsiva"}))
assert 'cameraAvailable: true' in html
assert "grab()" in html
def test_page_without_camera_is_as_before(logged_in_client, mock_api_client, monkeypatch):
html = _page(logged_in_client, mock_api_client, monkeypatch,
{**STATION, "camera_url": None}, TASK)
assert 'cameraAvailable: false' in html
assert "record()" not in html and "togglePause()" not in html and "grab()" not in html
@@ -0,0 +1,54 @@
"""gitea:Adriano/TieMeasureFlow#96 - l'immagine del task visione sta in fit, senza scorrimento.
Controllo sulle classi del template: pannello overflow-hidden, img/canvas
object-contain sovrapposti, nessun h-auto sull'immagine (era la causa dello scorrimento).
"""
from pathlib import Path
TEMPLATE = Path(__file__).resolve().parents[1] / "templates" / "measure" / "task_execute.html"
def _panel():
src = TEMPLATE.read_text(encoding="utf-8")
start = src.rindex("<div", 0, src.index('x-data="visionExecute()"'))
end = src.index("MEASUREMENT TIMER BANNER")
return src[start:end]
def _tag_with(panel, marker):
# Il tag (aperto) che contiene il marker; i '>' dentro le virgolette (es. "=>") non lo chiudono.
pos = panel.index(marker)
i = panel.rindex("<", 0, pos)
quote = None
for j in range(i, len(panel)):
c = panel[j]
if quote:
quote = None if c == quote else quote
elif c in "\"'":
quote = c
elif c == ">":
return panel[i:j + 1]
raise AssertionError(marker)
def test_panel_does_not_scroll():
tag = _tag_with(_panel(), 'x-data="visionExecute()"')
assert "overflow-hidden" in tag and "min-h-0" in tag
assert "overflow-auto" not in tag
def test_image_is_object_contain_without_h_auto():
panel = _panel()
img = _tag_with(panel, 'x-ref="img"')
assert "object-contain" in img
assert "h-auto" not in img
canvas = _tag_with(panel, 'x-ref="overlay"')
assert "object-contain" in canvas # stesso fit dell'img: overlay allineato
def test_image_box_and_outcome_column_can_shrink():
panel = _panel()
box = _tag_with(panel, 'x-show="shownImage"')
assert "flex-1" in box and "min-h-0" in box
outcome = _tag_with(panel, 'x-show="result">')
assert "overflow-y-auto" in outcome and "min-h-0" in outcome