Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 661b782bad | |||
| 8f9731839a | |||
| 552f96bb47 | |||
| 71daab184f |
@@ -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(); };
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)")
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -250,6 +250,8 @@ async def create_task(
|
|||||||
file_type=data.file_type,
|
file_type=data.file_type,
|
||||||
annotations_json=data.annotations_json,
|
annotations_json=data.annotations_json,
|
||||||
vision_json=data.vision_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
|
# #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),
|
in_loop=data.in_loop if data.in_loop is not None else default_in_loop(data.task_type),
|
||||||
at_close=data.at_close,
|
at_close=data.at_close,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from src.backend.api.routers.files import (
|
|||||||
from src.backend.config import settings
|
from src.backend.config import settings
|
||||||
from src.backend.database import get_db
|
from src.backend.database import get_db
|
||||||
from src.backend.models.api.vision import (
|
from src.backend.models.api.vision import (
|
||||||
VisionExecuteResponse, VisionMeasurementResult, VisionRecipeResponse,
|
VisionCheckResponse, VisionExecuteResponse, VisionMeasurementResult, VisionRecipeResponse,
|
||||||
VisionRecipeUploadResponse, VisionReferenceImageResponse,
|
VisionRecipeUploadResponse, VisionReferenceImageResponse,
|
||||||
)
|
)
|
||||||
from src.backend.models.orm.vision import VisionResult
|
from src.backend.models.orm.vision import VisionResult
|
||||||
@@ -164,6 +164,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")
|
@router.get("/results/{result_id}/image")
|
||||||
async def get_result_image(
|
async def get_result_image(
|
||||||
result_id: int,
|
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')
|
||||||
@@ -2,8 +2,9 @@
|
|||||||
import re
|
import re
|
||||||
from typing import Any, Literal, Optional
|
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
|
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.
|
# 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
|
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):
|
class TaskCreate(BaseModel):
|
||||||
"""Schema for creating a task."""
|
"""Schema for creating a task."""
|
||||||
title: str = Field(..., min_length=1, max_length=255)
|
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)$")
|
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||||
annotations_json: Optional[dict[str, Any]] = None
|
annotations_json: Optional[dict[str, Any]] = None
|
||||||
vision_json: Optional[dict] = None
|
vision_json: Optional[dict] = None
|
||||||
|
acquisition_json: Optional[AcquisitionConfig] = None # #97
|
||||||
# #53: assente = default dal tipo (misure e check nel ciclo)
|
# #53: assente = default dal tipo (misure e check nel ciclo)
|
||||||
in_loop: Optional[bool] = None
|
in_loop: Optional[bool] = None
|
||||||
at_close: bool = False
|
at_close: bool = False
|
||||||
@@ -186,6 +212,7 @@ class TaskUpdate(BaseModel):
|
|||||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||||
annotations_json: Optional[dict[str, Any]] = None
|
annotations_json: Optional[dict[str, Any]] = None
|
||||||
vision_json: Optional[dict] = None
|
vision_json: Optional[dict] = None
|
||||||
|
acquisition_json: Optional[AcquisitionConfig] = None # #97
|
||||||
in_loop: Optional[bool] = None
|
in_loop: Optional[bool] = None
|
||||||
at_close: Optional[bool] = None
|
at_close: Optional[bool] = None
|
||||||
fields: Optional[list[dict[str, Any]]] = None
|
fields: Optional[list[dict[str, Any]]] = None
|
||||||
@@ -207,6 +234,7 @@ class TaskResponse(BaseModel):
|
|||||||
file_type: Optional[str] = None
|
file_type: Optional[str] = None
|
||||||
annotations_json: Optional[dict[str, Any]] = None
|
annotations_json: Optional[dict[str, Any]] = None
|
||||||
vision_json: Optional[dict] = None
|
vision_json: Optional[dict] = None
|
||||||
|
acquisition_json: Optional[AcquisitionConfig] = None # #97
|
||||||
in_loop: bool = False
|
in_loop: bool = False
|
||||||
at_close: bool = False
|
at_close: bool = False
|
||||||
fields: Optional[list[dict[str, Any]]] = None
|
fields: Optional[list[dict[str, Any]]] = None
|
||||||
|
|||||||
@@ -24,6 +24,16 @@ class VisionExecuteResponse(BaseModel):
|
|||||||
failures: list[dict] = []
|
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):
|
class VisionRecipeOutput(BaseModel):
|
||||||
"""One numeric output a recipe offers to the quotes."""
|
"""One numeric output a recipe offers to the quotes."""
|
||||||
key: str
|
key: str
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ class RecipeTask(Base):
|
|||||||
# Cartella immutabile (relativa a upload_path) con recipe.json e i file di
|
# Cartella immutabile (relativa a upload_path) con recipe.json e i file di
|
||||||
# meta della ricetta VisionSuite v2 (#46).
|
# meta della ricetta VisionSuite v2 (#46).
|
||||||
vision_recipe_dir: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
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
|
# #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).
|
# check si', gli altri no; un task 'note' si puo' mettere nel ciclo a mano).
|
||||||
in_loop: Mapped[bool] = mapped_column(
|
in_loop: Mapped[bool] = mapped_column(
|
||||||
|
|||||||
@@ -117,6 +117,8 @@ async def _copy_tasks_to_version(
|
|||||||
vision_json=task.vision_json,
|
vision_json=task.vision_json,
|
||||||
# Cartella immutabile: basta il riferimento (#46).
|
# Cartella immutabile: basta il riferimento (#46).
|
||||||
vision_recipe_dir=task.vision_recipe_dir,
|
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.
|
# #53 #55: ciclo, chiusura e campi extra seguono il task nella nuova versione.
|
||||||
in_loop=task.in_loop,
|
in_loop=task.in_loop,
|
||||||
at_close=task.at_close,
|
at_close=task.at_close,
|
||||||
|
|||||||
@@ -231,35 +231,36 @@ CAL_NOT_CALIBRATED = "not_calibrated" # l'acquisitore risponde 404 (o un valore
|
|||||||
CAL_UNREACHABLE = "unreachable" # l'acquisitore non risponde
|
CAL_UNREACHABLE = "unreachable" # l'acquisitore non risponde
|
||||||
|
|
||||||
|
|
||||||
async def fetch_station_calibration(station: Station | None) -> tuple[dict | None, str]:
|
async def fetch_calibration(
|
||||||
"""Legge la calibrazione dall'acquisitore della stazione (C1, #83).
|
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
|
GET {camera_url}/camera/calibration?source={camera_source}. La calibrazione
|
||||||
vive nell'acquisitore: TMF la legge, non la tiene. Ritorna (dict, "ok")
|
vive nell'acquisitore: TMF la legge, non la tiene. Ritorna (dict, "ok")
|
||||||
oppure (None, motivo) per distinguere «non calibrata» da «irraggiungibile».
|
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
|
return None, CAL_NO_CAMERA
|
||||||
url = f"{station.camera_url.rstrip('/')}/camera/calibration"
|
url = f"{camera_url.rstrip('/')}/camera/calibration"
|
||||||
try:
|
try:
|
||||||
# timeout breve: e' una lettura di servizio, non deve bloccare l'execute
|
# timeout breve: e' una lettura di servizio, non deve bloccare l'execute
|
||||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
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:
|
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
|
return None, CAL_UNREACHABLE
|
||||||
if response.status_code == status.HTTP_404_NOT_FOUND:
|
if response.status_code == status.HTTP_404_NOT_FOUND:
|
||||||
return None, CAL_NOT_CALIBRATED
|
return None, CAL_NOT_CALIBRATED
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
logger.warning(
|
logger.warning("acquisitore %s: HTTP %s", label, response.status_code)
|
||||||
"acquisitore della stazione %s: HTTP %s", station.code, response.status_code,
|
|
||||||
)
|
|
||||||
return None, CAL_UNREACHABLE
|
return None, CAL_UNREACHABLE
|
||||||
try:
|
try:
|
||||||
body = response.json()
|
body = response.json()
|
||||||
except ValueError:
|
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
|
return None, CAL_UNREACHABLE
|
||||||
px_per_mm = body.get("px_per_mm") if isinstance(body, dict) else None
|
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):
|
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
|
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:
|
async def get_station_calibration(station: Station | None) -> dict | None:
|
||||||
"""La calibrazione C1 della stazione, o None in ogni altro caso (#83)."""
|
"""La calibrazione C1 della stazione, o None in ogni altro caso (#83)."""
|
||||||
calibration, _ = await fetch_station_calibration(station)
|
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
|
# Worker della stazione della sessione di produzione (#45); senza run o senza URL: centrale
|
||||||
station_url = None
|
station_url = None
|
||||||
# Calibrazione letta dall'acquisitore della stazione (#83); None = non c'e'
|
# Calibrazione letta dall'acquisitore (#83); None = non c'e'. #97: la camera e'
|
||||||
calibration = None
|
# quella del task se la ricetta ne sceglie una, altrimenti quella della stazione.
|
||||||
|
station = None
|
||||||
if run is not None:
|
if run is not None:
|
||||||
station = (await db.execute(
|
station = (await db.execute(
|
||||||
select(Station).where(Station.id == run.station_id)
|
select(Station).where(Station.id == run.station_id)
|
||||||
)).scalar_one_or_none()
|
)).scalar_one_or_none()
|
||||||
station_url = (station.vision_worker_url or None) if station is not None else 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:
|
if not task.vision_json:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -623,6 +652,85 @@ async def execute_task(
|
|||||||
return result, saved, report
|
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:
|
def _reference_file_path(reference: VisionReferenceImage) -> Path:
|
||||||
return settings.upload_path / reference.path
|
return settings.upload_path / reference.path
|
||||||
|
|
||||||
|
|||||||
@@ -341,3 +341,22 @@ class TestStationCameraMigration:
|
|||||||
assert "vision_worker_url" in cols("stations") # la 019 resta
|
assert "vision_worker_url" in cols("stations") # la 019 resta
|
||||||
command.upgrade(cfg, "head") # e di nuovo su
|
command.upgrade(cfg, "head") # e di nuovo su
|
||||||
assert {"camera_url", "camera_source"} <= cols("stations")
|
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")
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user