Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9eb5942459 |
+118
-652
@@ -1,194 +1,80 @@
|
||||
"""Pagina HTML dell'acquisizione camera (servita da camera_service su /calibrazione e /acquisizione, #83 #87).
|
||||
"""Pagina HTML della calibrazione a scacchiera (servita da camera_service su /calibrazione, #83).
|
||||
|
||||
Cosa fa: una pagina sola, autonoma e offline (niente CDN, niente font esterni,
|
||||
JS e CSS inline), in italiano, stile scuro TMF. Quattro sezioni: Sorgente,
|
||||
Anteprima (dal vivo, con nitidezza/saturazione/livello medio), Parametri camera
|
||||
(esposizione e guadagno), Calibrazione a scacchiera.
|
||||
Parla con le rotte dello stesso servizio: /camera/sources, /camera/anteprima,
|
||||
/camera/parametri, /camera/parametri/salva, /camera/calibration, /camera/calibrate.
|
||||
#84: calibrazione densa, un solo dato dell'operatore (lato dello scacco in mm).
|
||||
#87: anteprima, parametri camera, stile TMF.
|
||||
Cosa fa: una pagina sola, autonoma e offline (niente CDN, JS e CSS inline), in
|
||||
italiano. Parla con le rotte dello stesso servizio (/camera/sources,
|
||||
/camera/calibration, /camera/calibrate).
|
||||
#84: calibrazione densa, un solo dato dell'operatore (lato dello scacco in mm):
|
||||
scacchiera che riempie il campo, griglia trovata da sola, mappa di distorsione.
|
||||
Dipende da: niente. E' una stringa, cosi' il pacchetto compilato non ha file dati.
|
||||
"""
|
||||
|
||||
PAGINA = r"""<!doctype html>
|
||||
PAGINA = """<!doctype html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Acquisizione camera</title>
|
||||
<title>Calibrazione camera</title>
|
||||
<style>
|
||||
/* #87: stile scuro TMF (slate/blu), font di sistema */
|
||||
:root { --sfondo: #0f172a; --pannello: #1e293b; --bordo: #334155; --accento: #3b82f6;
|
||||
--testo: #e2e8f0; --secondario: #94a3b8; --errore: #f87171; --ok: #4ade80; --avviso: #fbbf24; }
|
||||
* { box-sizing: border-box; }
|
||||
[hidden] { display: none !important; } /* #87: vale anche dove il CSS imposta display */
|
||||
body { font-family: "Segoe UI", system-ui, sans-serif; margin: 0; background: var(--sfondo); color: var(--testo); }
|
||||
main { max-width: 1500px; margin: 0 auto; padding: 14px; }
|
||||
h1 { font-size: 1.35rem; margin: 0 0 12px; }
|
||||
h2 { font-size: 1.02rem; margin: 0 0 10px; }
|
||||
.griglia { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(320px, 1fr); gap: 14px; align-items: start; }
|
||||
.colonna { display: flex; flex-direction: column; gap: 14px; min-width: 0; }
|
||||
.scheda { background: var(--pannello); border: 1px solid var(--bordo); border-radius: 8px; padding: 14px; }
|
||||
label { display: block; font-size: .82rem; margin-bottom: 4px; color: var(--secondario); }
|
||||
select, input[type=text] { padding: 6px 8px; font-size: .95rem; color: var(--testo); background: var(--sfondo);
|
||||
border: 1px solid var(--bordo); border-radius: 6px; font-family: inherit; }
|
||||
select { max-width: 100%; }
|
||||
select:focus, input:focus, button:focus-visible { outline: 2px solid var(--accento); outline-offset: 1px; }
|
||||
input[type=range] { width: 100%; accent-color: var(--accento); }
|
||||
input[type=checkbox] { accent-color: var(--accento); }
|
||||
.riga { display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-end; }
|
||||
.nota { font-size: .82rem; color: var(--secondario); margin: 8px 0 0; }
|
||||
button { padding: 7px 13px; font-size: .95rem; border: 0; border-radius: 6px; background: var(--accento); color: #fff;
|
||||
cursor: pointer; font-family: inherit; }
|
||||
button.secondario { background: var(--bordo); color: var(--testo); }
|
||||
body { font-family: system-ui, sans-serif; margin: 0; background: #f3f4f6; color: #1f2937; }
|
||||
main { max-width: 960px; margin: 0 auto; padding: 16px; }
|
||||
h1 { font-size: 1.4rem; margin: 0 0 12px; }
|
||||
.scheda { background: #fff; border: 1px solid #d1d5db; border-radius: 8px; padding: 14px; margin-bottom: 14px; }
|
||||
label { display: block; font-size: .85rem; margin-bottom: 4px; color: #4b5563; }
|
||||
select, input[type=number] { padding: 6px 8px; font-size: 1rem; border: 1px solid #9ca3af; border-radius: 6px; }
|
||||
.riga { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; }
|
||||
button { padding: 8px 14px; font-size: 1rem; border: 0; border-radius: 6px; background: #2563eb; color: #fff; cursor: pointer; }
|
||||
button.secondario { background: #6b7280; }
|
||||
button.pericolo { background: #b91c1c; }
|
||||
button:disabled { opacity: .45; cursor: default; }
|
||||
.messaggio { min-height: 1.2em; margin-top: 8px; font-size: .9rem; font-weight: 600; color: var(--secondario); }
|
||||
.errore { color: var(--errore); }
|
||||
.ok { color: var(--ok); }
|
||||
.avviso { color: var(--avviso); font-size: .88rem; }
|
||||
table { border-collapse: collapse; font-size: .88rem; }
|
||||
td { padding: 2px 14px 2px 0; }
|
||||
td:first-child { color: var(--secondario); }
|
||||
.stato { font-size: .9rem; color: var(--secondario); margin-top: 8px; }
|
||||
/* anteprima */
|
||||
.palco { background: #020617; border: 1px solid var(--bordo); border-radius: 6px; min-height: 340px;
|
||||
display: flex; align-items: center; justify-content: center; margin-top: 10px; overflow: hidden; }
|
||||
.cornice { position: relative; display: inline-block; line-height: 0; max-width: 100%; }
|
||||
.cornice img { max-width: 100%; max-height: calc(100vh - 330px); min-height: 200px; border: 0; margin: 0; }
|
||||
#mirino { position: absolute; inset: 0; pointer-events: none;
|
||||
background: linear-gradient(#fbbf24, #fbbf24) center / 2px 100% no-repeat,
|
||||
linear-gradient(#fbbf24, #fbbf24) center / 100% 2px no-repeat; opacity: .85; }
|
||||
#vuoto { padding: 30px 16px; color: var(--secondario); text-align: center; }
|
||||
.indicatori { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin-top: 10px; }
|
||||
.indicatore { background: var(--sfondo); border: 1px solid var(--bordo); border-radius: 6px; padding: 8px 10px; }
|
||||
.indicatore .nome { font-size: .78rem; color: var(--secondario); }
|
||||
.indicatore .valore { font-size: 1.35rem; font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
.indicatore .piccolo { font-size: .76rem; color: var(--secondario); }
|
||||
.barra { height: 6px; background: var(--bordo); border-radius: 3px; margin-top: 4px; overflow: hidden; }
|
||||
.barra > div { height: 100%; width: 0; background: var(--accento); transition: width .15s; }
|
||||
.rosso { color: var(--errore); }
|
||||
/* parametri */
|
||||
.parametro { margin-bottom: 12px; }
|
||||
.parametro .campo { display: flex; align-items: center; gap: 6px; }
|
||||
.parametro .campo input[type=text] { width: 110px; text-align: right; flex: none; }
|
||||
.parametro .unita { font-size: .82rem; color: var(--secondario); min-width: 2.2em; }
|
||||
.parametro .limiti { font-size: .75rem; color: var(--secondario); }
|
||||
@media (max-width: 900px) {
|
||||
.griglia { grid-template-columns: minmax(0, 1fr); }
|
||||
.cornice img { max-height: 60vh; }
|
||||
}
|
||||
button:disabled { opacity: .5; cursor: default; }
|
||||
.messaggio { margin-top: 10px; font-weight: 600; }
|
||||
.errore { color: #b91c1c; }
|
||||
.ok { color: #15803d; }
|
||||
table { border-collapse: collapse; }
|
||||
td { padding: 3px 14px 3px 0; }
|
||||
td:first-child { color: #4b5563; }
|
||||
img { max-width: 100%; border: 1px solid #d1d5db; border-radius: 6px; margin-top: 8px; }
|
||||
.avviso { color: #92400e; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Acquisizione camera</h1>
|
||||
<h1>Calibrazione camera con scacchiera</h1>
|
||||
|
||||
<div class="griglia">
|
||||
<div class="colonna">
|
||||
<!-- #87: sezione Anteprima -->
|
||||
<section class="scheda" id="sezioneAnteprima">
|
||||
<h2>Anteprima</h2>
|
||||
<div class="riga">
|
||||
<button id="avvia">Avvia anteprima</button>
|
||||
<button id="ferma" class="secondario" disabled>Ferma anteprima</button>
|
||||
<button id="scatta" class="secondario">Scatta</button>
|
||||
<label style="margin:0 0 6px"><input id="grezzo" type="checkbox"> Immagine grezza (senza correzione)</label>
|
||||
<label style="margin:0 0 6px"><input id="mirinoCasella" type="checkbox"> Mirino centrale</label>
|
||||
</div>
|
||||
<div class="palco">
|
||||
<div id="vuoto">Anteprima ferma. Premere «Avvia anteprima» o «Scatta».</div>
|
||||
<div class="cornice" id="cornice" hidden>
|
||||
<img id="immagine" alt="Anteprima della camera">
|
||||
<div id="mirino" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="indicatori">
|
||||
<div class="indicatore">
|
||||
<div class="nome">Nitidezza</div>
|
||||
<div class="valore" id="nitidezza">-</div>
|
||||
<div class="barra"><div id="barraNitidezza"></div></div>
|
||||
<div class="piccolo">massimo visto: <span id="nitidezzaMax">-</span>
|
||||
<button id="azzeraMax" class="secondario" style="padding:1px 7px;font-size:.75rem;margin-left:6px">Azzera</button></div>
|
||||
</div>
|
||||
<div class="indicatore">
|
||||
<div class="nome">Saturati</div>
|
||||
<div class="valore" id="saturati">-</div>
|
||||
<div class="piccolo">pixel a 250 o più; da tenere sotto l'1 %</div>
|
||||
</div>
|
||||
<div class="indicatore">
|
||||
<div class="nome">Livello medio</div>
|
||||
<div class="valore" id="media">-</div>
|
||||
<div class="piccolo">scala 0-255</div>
|
||||
</div>
|
||||
<div class="indicatore">
|
||||
<div class="nome">Frame pieno</div>
|
||||
<div class="valore" id="dimensioni">-</div>
|
||||
<div class="piccolo" id="raddrizzato"> </div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="msgAnteprima" class="messaggio"></div>
|
||||
</section>
|
||||
<div class="scheda">
|
||||
<div class="riga">
|
||||
<div><label for="sorgente">Sorgente</label><select id="sorgente"></select></div>
|
||||
<div><label for="quadrato">Lato dello scacco (mm)</label><input id="quadrato" type="number" min="0.001" step="any" value="2"></div>
|
||||
</div>
|
||||
<p style="font-size:.85rem;color:#4b5563;margin:10px 0 0">
|
||||
Servono una scacchiera che riempie tutto il campo della camera, piana, ferma e ben illuminata, e il lato di uno scacco.
|
||||
La griglia si trova da sola; il risultato corregge anche la distorsione dell'ottica.</p>
|
||||
</div>
|
||||
|
||||
<div class="colonna">
|
||||
<!-- #87: sezione Sorgente -->
|
||||
<section class="scheda" id="sezioneSorgente">
|
||||
<h2>Sorgente</h2>
|
||||
<label for="sorgente">Camera o cartella</label>
|
||||
<select id="sorgente"></select>
|
||||
<div class="stato" id="statoSorgente">-</div>
|
||||
<div id="msgSorgente" class="messaggio"></div>
|
||||
</section>
|
||||
<div class="scheda">
|
||||
<div class="riga">
|
||||
<button id="acquisisci">Acquisisci e calibra</button>
|
||||
<div><label for="file">oppure un file immagine</label><input id="file" type="file" accept="image/*"></div>
|
||||
<button id="carica" class="secondario">Carica immagine e calibra</button>
|
||||
</div>
|
||||
<div id="messaggio" class="messaggio"></div>
|
||||
</div>
|
||||
|
||||
<!-- #87: sezione Parametri camera -->
|
||||
<section class="scheda" id="sezioneParametri">
|
||||
<h2>Parametri camera</h2>
|
||||
<div id="parametri"><div class="nota">-</div></div>
|
||||
<div class="riga" id="salvaParametriRiga" hidden>
|
||||
<button id="salvaParametri" class="secondario">Salva come predefiniti</button>
|
||||
</div>
|
||||
<div id="msgParametri" class="messaggio"></div>
|
||||
</section>
|
||||
<div class="scheda" id="risultato" hidden>
|
||||
<strong>Risultato del calcolo</strong>
|
||||
<table id="tabella"></table>
|
||||
<div id="avviso" class="avviso"></div>
|
||||
<div class="riga" style="margin-top:10px">
|
||||
<button id="salva">Salva</button>
|
||||
<span style="font-size:.85rem;color:#4b5563">Da camera, salvando si acquisisce e si calcola di nuovo: tenere la scacchiera ferma.</span>
|
||||
</div>
|
||||
<img id="overlay" alt="Angoli trovati sulla scacchiera">
|
||||
</div>
|
||||
|
||||
<section class="scheda" id="sezioneCalibrazione">
|
||||
<h2>Calibrazione</h2>
|
||||
<div class="riga">
|
||||
<div><label for="quadrato">Lato dello scacco (mm)</label><input id="quadrato" type="text" inputmode="decimal" value="2" size="8"></div>
|
||||
</div>
|
||||
<p class="nota">
|
||||
Servono una scacchiera che riempie tutto il campo della camera, piana, ferma e ben illuminata, e il lato di uno scacco.
|
||||
La griglia si trova da sola; il risultato corregge anche la distorsione dell'ottica.
|
||||
Durante il calcolo l'anteprima si sospende.</p>
|
||||
<div class="riga" style="margin-top:10px">
|
||||
<button id="acquisisci">Acquisisci e calibra</button>
|
||||
<button id="scegliFile" class="secondario">Scegli immagine...</button>
|
||||
<input id="file" type="file" accept="image/*" hidden>
|
||||
<span id="nomeFile" class="nota" style="margin:0 0 6px">nessuna immagine scelta</span>
|
||||
<button id="carica" class="secondario">Carica immagine e calibra</button>
|
||||
</div>
|
||||
<div id="messaggio" class="messaggio"></div>
|
||||
|
||||
<div id="risultato" hidden style="margin-top:10px">
|
||||
<strong>Risultato del calcolo</strong>
|
||||
<table id="tabella"></table>
|
||||
<div id="avviso" class="avviso"></div>
|
||||
<div class="riga" style="margin-top:10px">
|
||||
<button id="salva">Salva</button>
|
||||
<span class="nota" style="margin:0 0 6px">Da camera, salvando si acquisisce e si calcola di nuovo: tenere la scacchiera ferma.</span>
|
||||
</div>
|
||||
<img id="overlay" alt="Angoli trovati sulla scacchiera" style="max-width:100%;margin-top:8px;border:1px solid var(--bordo);border-radius:6px">
|
||||
</div>
|
||||
|
||||
<div style="margin-top:14px">
|
||||
<strong>Calibrazione salvata</strong>
|
||||
<div id="stato" class="stato">-</div>
|
||||
<div class="riga" style="margin-top:10px">
|
||||
<button id="elimina" class="pericolo" disabled>Elimina calibrazione</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="scheda">
|
||||
<strong>Calibrazione salvata</strong>
|
||||
<div id="stato">-</div>
|
||||
<div class="riga" style="margin-top:10px">
|
||||
<button id="elimina" class="pericolo" disabled>Elimina</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -196,437 +82,31 @@ PAGINA = r"""<!doctype html>
|
||||
<script>
|
||||
"use strict";
|
||||
var $ = function (id) { return document.getElementById(id); };
|
||||
var ultimo = null; // ultimo calcolo di calibrazione, per «Salva»
|
||||
var ultimo = null; // ultimo calcolo, per «Salva»
|
||||
|
||||
// ---- utilita'
|
||||
|
||||
// numero all'italiana (virgola decimale); cifre = decimali fissi, omesso = fino a 4
|
||||
function fmt(v, cifre) {
|
||||
var n = Number(v);
|
||||
if (!isFinite(n)) return "-";
|
||||
var o = cifre === undefined ? { maximumFractionDigits: 4 } : { minimumFractionDigits: cifre, maximumFractionDigits: cifre };
|
||||
return n.toLocaleString("it-IT", o);
|
||||
}
|
||||
// numero senza separatore delle migliaia, per le caselle di input
|
||||
function fmtCasella(v) { return Number(v).toLocaleString("it-IT", { maximumFractionDigits: 6, useGrouping: false }); }
|
||||
// legge un numero scritto con virgola o con punto
|
||||
function leggiNumero(testo) {
|
||||
var n = Number(String(testo).split(" ").join("").replace(",", "."));
|
||||
return isFinite(n) && String(testo).trim() !== "" ? n : NaN;
|
||||
}
|
||||
function dataItaliana(iso) {
|
||||
var d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return String(iso);
|
||||
return d.toLocaleString("it-IT", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" }).replace(",", "");
|
||||
}
|
||||
function dire(id, testo, classe) {
|
||||
var m = $(id);
|
||||
function messaggio(testo, classe) {
|
||||
var m = $("messaggio");
|
||||
m.textContent = testo || "";
|
||||
m.className = "messaggio " + (classe || "");
|
||||
}
|
||||
function messaggio(testo, classe) { dire("messaggio", testo, classe); }
|
||||
function pausa(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
|
||||
function memoria(chiave, valore) { // localStorage sempre protetto: puo' essere bloccato
|
||||
try {
|
||||
if (valore === undefined) return localStorage.getItem(chiave);
|
||||
localStorage.setItem(chiave, valore);
|
||||
} catch (e) { /* niente memoria, la pagina funziona lo stesso */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
// testo d'errore da una risposta; #87: rotte nuove assenti in un servizio vecchio -> messaggio chiaro
|
||||
async function dettaglio(risposta) {
|
||||
var testo;
|
||||
try { var j = await risposta.json(); testo = j.detail || JSON.stringify(j); }
|
||||
catch (e) { testo = "errore " + risposta.status; }
|
||||
if (testo === "Not Found" || testo === "Method Not Allowed") {
|
||||
return "Funzione non disponibile: il servizio della camera è una versione più vecchia (errore " + risposta.status + ").";
|
||||
}
|
||||
return typeof testo === "string" ? testo : JSON.stringify(testo);
|
||||
}
|
||||
|
||||
// ---- Sorgente
|
||||
|
||||
async function caricaSorgenti() {
|
||||
var sel = $("sorgente");
|
||||
try {
|
||||
var r = await fetch("/camera/sources");
|
||||
var j = await r.json();
|
||||
(j.sources || []).forEach(function (s) {
|
||||
var o = document.createElement("option");
|
||||
o.value = s.name;
|
||||
o.textContent = s.label || s.name;
|
||||
sel.appendChild(o);
|
||||
});
|
||||
} catch (e) { dire("msgSorgente", "Elenco delle sorgenti non disponibile.", "errore"); }
|
||||
if (!sel.options.length) dire("msgSorgente", "Nessuna sorgente camera trovata.", "errore");
|
||||
var ricordata = memoria("tmf.acq.sorgente"); // #87: ultima scelta ricordata
|
||||
if (ricordata) {
|
||||
for (var i = 0; i < sel.options.length; i++) if (sel.options[i].value === ricordata) sel.value = ricordata;
|
||||
}
|
||||
cambioSorgente(false);
|
||||
}
|
||||
|
||||
// #87: cambiando sorgente si azzerano gli indicatori e si rileggono parametri e calibrazione
|
||||
function cambioSorgente(salva) {
|
||||
if (salva) memoria("tmf.acq.sorgente", $("sorgente").value);
|
||||
var era = A.attiva;
|
||||
fermaAnteprima();
|
||||
azzeraIndicatori();
|
||||
dire("msgAnteprima", "");
|
||||
aggiornaStato();
|
||||
caricaParametri();
|
||||
if (era) avviaAnteprima();
|
||||
}
|
||||
|
||||
// ---- Anteprima (#87)
|
||||
|
||||
var A = { attiva: false, gen: 0, inVolo: false, url: null, nitMax: 0 };
|
||||
|
||||
function azzeraIndicatori() {
|
||||
A.nitMax = 0;
|
||||
["nitidezza", "saturati", "media", "dimensioni", "nitidezzaMax"].forEach(function (i) { $(i).textContent = "-"; });
|
||||
$("saturati").className = "valore";
|
||||
$("barraNitidezza").style.width = "0";
|
||||
$("raddrizzato").textContent = " ";
|
||||
}
|
||||
|
||||
function pulsantiAnteprima() {
|
||||
$("avvia").disabled = A.attiva;
|
||||
$("ferma").disabled = !A.attiva;
|
||||
}
|
||||
|
||||
function mostraImmagine(blob) {
|
||||
var vecchio = A.url;
|
||||
A.url = URL.createObjectURL(blob);
|
||||
$("immagine").src = A.url;
|
||||
if (vecchio) URL.revokeObjectURL(vecchio); // niente memoria che cresce a ogni frame
|
||||
$("cornice").hidden = false;
|
||||
$("vuoto").hidden = true;
|
||||
}
|
||||
|
||||
function aggiornaIndicatori(h) {
|
||||
var nit = parseFloat(h.get("X-Nitidezza"));
|
||||
var sat = parseFloat(h.get("X-Saturati-Pct"));
|
||||
var media = parseFloat(h.get("X-Media"));
|
||||
if (isFinite(nit)) {
|
||||
if (nit > A.nitMax) A.nitMax = nit;
|
||||
$("nitidezza").textContent = fmt(nit, 1);
|
||||
$("nitidezzaMax").textContent = fmt(A.nitMax, 1);
|
||||
$("barraNitidezza").style.width = (A.nitMax > 0 ? Math.round(100 * nit / A.nitMax) : 0) + "%";
|
||||
}
|
||||
if (isFinite(sat)) {
|
||||
$("saturati").textContent = fmt(sat, 2) + " %";
|
||||
$("saturati").className = "valore" + (sat > 1 ? " rosso" : ""); // oltre l'1 % si perde dettaglio
|
||||
}
|
||||
if (isFinite(media)) $("media").textContent = fmt(media, 1);
|
||||
var w = h.get("X-Larghezza"), a = h.get("X-Altezza");
|
||||
if (w && a) $("dimensioni").textContent = fmt(w, 0) + " x " + fmt(a, 0);
|
||||
var r = h.get("X-Raddrizzato");
|
||||
$("raddrizzato").textContent = r === "si" ? "raddrizzata dalla calibrazione" : r === "no" ? "non raddrizzata" : " ";
|
||||
}
|
||||
|
||||
// una richiesta di anteprima; ritorna "ok" | "errore" | "fatale" (errore che non passa riprovando)
|
||||
async function unaAnteprima(lato) {
|
||||
var sorgente = $("sorgente").value;
|
||||
if (!sorgente) { dire("msgAnteprima", "Scegliere una sorgente.", "errore"); return "fatale"; }
|
||||
var g = A.gen;
|
||||
A.inVolo = true;
|
||||
try {
|
||||
var r = await fetch("/camera/anteprima", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ source: sorgente, grezzo: $("grezzo").checked, lato_max: lato, timeout_ms: 5000 })
|
||||
});
|
||||
if (!r.ok) {
|
||||
var d = await dettaglio(r);
|
||||
if (g === A.gen) dire("msgAnteprima", d, "errore");
|
||||
return r.status >= 500 ? "errore" : "fatale"; // 503/504: la camera puo' tornare
|
||||
}
|
||||
var blob = await r.blob();
|
||||
if (g !== A.gen) return "ok"; // frame di una sorgente o di un ciclo ormai chiuso: si scarta
|
||||
mostraImmagine(blob);
|
||||
aggiornaIndicatori(r.headers);
|
||||
dire("msgAnteprima", "");
|
||||
return "ok";
|
||||
} catch (e) {
|
||||
if (g === A.gen) dire("msgAnteprima", "Servizio non raggiungibile.", "errore");
|
||||
return "errore";
|
||||
} finally {
|
||||
A.inVolo = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ciclo sequenziale: la richiesta dopo parte solo a risposta arrivata, almeno 300 ms dopo la precedente
|
||||
async function ciclo(g) {
|
||||
while (A.inVolo) await pausa(50);
|
||||
while (A.attiva && g === A.gen) {
|
||||
var t0 = Date.now();
|
||||
var esito = await unaAnteprima(1280);
|
||||
if (g !== A.gen) return;
|
||||
if (esito === "fatale") { fermaAnteprima(); return; }
|
||||
await pausa(esito === "errore" ? 1500 : Math.max(300 - (Date.now() - t0), 0));
|
||||
}
|
||||
}
|
||||
|
||||
function avviaAnteprima() {
|
||||
if (A.attiva) return;
|
||||
A.attiva = true;
|
||||
A.gen++;
|
||||
pulsantiAnteprima();
|
||||
ciclo(A.gen);
|
||||
}
|
||||
|
||||
function fermaAnteprima() {
|
||||
A.attiva = false;
|
||||
A.gen++;
|
||||
pulsantiAnteprima();
|
||||
}
|
||||
|
||||
// «Scatta»: ferma il ciclo e mostra un frame solo, alla risoluzione piena (fino a 4096 px)
|
||||
async function scatta() {
|
||||
fermaAnteprima();
|
||||
$("scatta").disabled = true;
|
||||
dire("msgAnteprima", "Scatto in corso...", "");
|
||||
while (A.inVolo) await pausa(50);
|
||||
await unaAnteprima(4096);
|
||||
$("scatta").disabled = false;
|
||||
}
|
||||
|
||||
$("avvia").onclick = avviaAnteprima;
|
||||
$("ferma").onclick = fermaAnteprima;
|
||||
$("scatta").onclick = scatta;
|
||||
$("azzeraMax").onclick = function () { A.nitMax = 0; $("nitidezzaMax").textContent = "-"; $("barraNitidezza").style.width = "0"; };
|
||||
$("mirinoCasella").onchange = function () { $("mirino").hidden = !$("mirinoCasella").checked; };
|
||||
|
||||
// ---- Parametri camera (#87)
|
||||
|
||||
var ORDINE = ["esposizione", "esposizione_auto", "guadagno", "guadagno_auto"];
|
||||
var ETICHETTE = { esposizione: "Esposizione", esposizione_auto: "Esposizione automatica",
|
||||
guadagno: "Guadagno", guadagno_auto: "Guadagno automatico" };
|
||||
var UNITA = { esposizione: "µs", guadagno: "dB" };
|
||||
var ETICHETTE_SCELTE = { Off: "Spenta", Once: "Una volta", Continuous: "Continua" };
|
||||
var P = { el: {}, dati: {}, pendenti: {}, timer: null, inScrittura: false, gen: 0 };
|
||||
|
||||
function usaLog(d) { return typeof d.min === "number" && typeof d.max === "number" && d.min > 0 && d.max / d.min >= 100; }
|
||||
function haCursore(d) { return typeof d.min === "number" && typeof d.max === "number" && d.max > d.min; }
|
||||
function aPosizione(d, v) {
|
||||
var t = usaLog(d) ? Math.log(Math.max(v, d.min) / d.min) / Math.log(d.max / d.min) : (v - d.min) / (d.max - d.min);
|
||||
return Math.round(1000 * Math.min(Math.max(t, 0), 1));
|
||||
}
|
||||
function daPosizione(d, p) {
|
||||
var t = p / 1000;
|
||||
return usaLog(d) ? d.min * Math.pow(d.max / d.min, t) : d.min + t * (d.max - d.min);
|
||||
}
|
||||
// porta il valore nei limiti e sul passo della camera
|
||||
function adatta(d, v) {
|
||||
if (typeof d.min === "number") v = Math.max(v, d.min);
|
||||
if (typeof d.max === "number") v = Math.min(v, d.max);
|
||||
if (typeof d.passo === "number" && d.passo > 0) {
|
||||
var base = typeof d.min === "number" ? d.min : 0;
|
||||
v = base + Math.round((v - base) / d.passo) * d.passo;
|
||||
if (typeof d.max === "number") v = Math.min(v, d.max);
|
||||
}
|
||||
return Number(v.toFixed(6));
|
||||
}
|
||||
|
||||
async function caricaParametri() {
|
||||
var sorgente = $("sorgente").value;
|
||||
var g = ++P.gen;
|
||||
P.pendenti = {};
|
||||
clearTimeout(P.timer);
|
||||
$("salvaParametriRiga").hidden = true;
|
||||
dire("msgParametri", "");
|
||||
if (!sorgente) { $("parametri").textContent = "-"; return; }
|
||||
try {
|
||||
var r = await fetch("/camera/parametri?source=" + encodeURIComponent(sorgente));
|
||||
if (g !== P.gen) return;
|
||||
if (!r.ok) {
|
||||
$("parametri").textContent = "";
|
||||
dire("msgParametri", await dettaglio(r), "errore");
|
||||
return;
|
||||
}
|
||||
costruisciParametri(await r.json());
|
||||
} catch (e) {
|
||||
if (g === P.gen) dire("msgParametri", "Servizio non raggiungibile.", "errore");
|
||||
}
|
||||
}
|
||||
|
||||
function costruisciParametri(j) {
|
||||
var box = $("parametri");
|
||||
box.textContent = "";
|
||||
P.el = {};
|
||||
P.dati = j.parametri || {};
|
||||
var nomi = ORDINE.filter(function (n) { return P.dati[n]; });
|
||||
if (!j.regolabile || !nomi.length) {
|
||||
var n = document.createElement("div");
|
||||
n.className = "nota";
|
||||
n.textContent = "Questa sorgente non ha parametri regolabili.";
|
||||
box.appendChild(n);
|
||||
$("salvaParametriRiga").hidden = true;
|
||||
return;
|
||||
}
|
||||
nomi.forEach(function (nome) { box.appendChild(creaParametro(nome, P.dati[nome])); });
|
||||
$("salvaParametriRiga").hidden = false;
|
||||
}
|
||||
|
||||
function creaParametro(nome, d) {
|
||||
var riga = document.createElement("div");
|
||||
riga.className = "parametro";
|
||||
var et = document.createElement("label");
|
||||
et.textContent = ETICHETTE[nome] + (UNITA[nome] ? " (" + UNITA[nome] + ")" : "");
|
||||
riga.appendChild(et);
|
||||
var el = { tipo: d.tipo };
|
||||
var bloccato = d.scrivibile === false;
|
||||
if (d.tipo === "scelta") {
|
||||
var sel = document.createElement("select");
|
||||
(d.scelte || []).forEach(function (s) {
|
||||
var o = document.createElement("option");
|
||||
o.value = s;
|
||||
o.textContent = ETICHETTE_SCELTE[s] || s;
|
||||
sel.appendChild(o);
|
||||
});
|
||||
sel.value = d.valore;
|
||||
sel.disabled = bloccato;
|
||||
sel.onchange = function () { modifica(nome, sel.value); };
|
||||
riga.appendChild(sel);
|
||||
el.select = sel;
|
||||
} else {
|
||||
var campo = document.createElement("div");
|
||||
campo.className = "campo";
|
||||
var cur = null;
|
||||
var casella = document.createElement("input");
|
||||
if (haCursore(d)) {
|
||||
cur = document.createElement("input");
|
||||
cur.type = "range";
|
||||
cur.min = 0; cur.max = 1000; cur.step = 1;
|
||||
cur.value = aPosizione(d, d.valore);
|
||||
cur.disabled = bloccato;
|
||||
cur.oninput = function () { // il cursore aggiorna la casella subito, la scrittura arriva dopo 300 ms
|
||||
var v = adatta(d, daPosizione(d, Number(cur.value)));
|
||||
casella.value = fmtCasella(v);
|
||||
modifica(nome, v);
|
||||
};
|
||||
campo.appendChild(cur);
|
||||
}
|
||||
casella.type = "text";
|
||||
casella.inputMode = "decimal";
|
||||
casella.value = fmtCasella(d.valore);
|
||||
casella.disabled = bloccato;
|
||||
casella.onchange = function () {
|
||||
var v = leggiNumero(casella.value);
|
||||
if (isNaN(v)) { dire("msgParametri", "Valore non valido per " + ETICHETTE[nome].toLowerCase() + ".", "errore"); casella.value = fmtCasella(P.dati[nome].valore); return; }
|
||||
v = adatta(d, v);
|
||||
casella.value = fmtCasella(v);
|
||||
if (cur) cur.value = aPosizione(d, v);
|
||||
modifica(nome, v);
|
||||
};
|
||||
campo.appendChild(casella);
|
||||
var u = document.createElement("span");
|
||||
u.className = "unita";
|
||||
u.textContent = UNITA[nome] || "";
|
||||
campo.appendChild(u);
|
||||
riga.appendChild(campo);
|
||||
if (typeof d.min === "number" || typeof d.max === "number") {
|
||||
var lim = document.createElement("div");
|
||||
lim.className = "limiti";
|
||||
lim.textContent = "da " + (typeof d.min === "number" ? fmt(d.min) : "?") + " a " + (typeof d.max === "number" ? fmt(d.max) : "?") +
|
||||
(typeof d.passo === "number" ? ", passo " + fmt(d.passo) : "") + (usaLog(d) ? " (cursore logaritmico)" : "");
|
||||
riga.appendChild(lim);
|
||||
}
|
||||
el.cursore = cur;
|
||||
el.casella = casella;
|
||||
}
|
||||
P.el[nome] = el;
|
||||
return riga;
|
||||
}
|
||||
|
||||
// una modifica dell'operatore: si accoda e parte dopo 300 ms dall'ultima
|
||||
function modifica(nome, valore) {
|
||||
P.pendenti[nome] = valore;
|
||||
var aut = nome + "_auto";
|
||||
// impostare un valore manuale spegne prima l'automatico corrispondente
|
||||
if ((nome === "esposizione" || nome === "guadagno") && P.el[aut] && P.el[aut].select && P.el[aut].select.value !== "Off") {
|
||||
P.pendenti[aut] = "Off";
|
||||
P.el[aut].select.value = "Off";
|
||||
}
|
||||
clearTimeout(P.timer);
|
||||
P.timer = setTimeout(scriviParametri, 300);
|
||||
}
|
||||
|
||||
async function scriviParametri() {
|
||||
if (P.inScrittura) { P.timer = setTimeout(scriviParametri, 100); return; }
|
||||
var valori = P.pendenti;
|
||||
P.pendenti = {};
|
||||
if (!Object.keys(valori).length) return;
|
||||
P.inScrittura = true;
|
||||
try {
|
||||
var r = await fetch("/camera/parametri", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ source: $("sorgente").value, valori: valori })
|
||||
});
|
||||
if (!r.ok) {
|
||||
dire("msgParametri", await dettaglio(r), "errore");
|
||||
if (r.status !== 404 && r.status !== 405) caricaParametri(); // si rilegge cosa ha la camera
|
||||
return;
|
||||
}
|
||||
aggiornaValori(await r.json());
|
||||
dire("msgParametri", "Parametri applicati.", "ok");
|
||||
} catch (e) {
|
||||
dire("msgParametri", "Servizio non raggiungibile.", "errore");
|
||||
} finally {
|
||||
P.inScrittura = false;
|
||||
}
|
||||
}
|
||||
|
||||
// rilegge i valori dalla risposta, lasciando stare quelli che l'operatore sta ancora toccando
|
||||
function aggiornaValori(j) {
|
||||
var dati = j.parametri || {};
|
||||
Object.keys(dati).forEach(function (nome) {
|
||||
var el = P.el[nome], d = dati[nome];
|
||||
if (!el || P.pendenti.hasOwnProperty(nome)) return;
|
||||
P.dati[nome].valore = d.valore;
|
||||
if (el.select) el.select.value = d.valore;
|
||||
if (el.casella && document.activeElement !== el.casella) el.casella.value = fmtCasella(d.valore);
|
||||
if (el.cursore && document.activeElement !== el.cursore) el.cursore.value = aPosizione(P.dati[nome], d.valore);
|
||||
});
|
||||
}
|
||||
|
||||
$("salvaParametri").onclick = async function () {
|
||||
$("salvaParametri").disabled = true;
|
||||
try {
|
||||
var r = await fetch("/camera/parametri/salva", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ source: $("sorgente").value })
|
||||
});
|
||||
if (!r.ok) dire("msgParametri", await dettaglio(r), "errore");
|
||||
else dire("msgParametri", "Predefiniti salvati: si ricaricano all'apertura della camera.", "ok");
|
||||
} catch (e) {
|
||||
dire("msgParametri", "Servizio non raggiungibile.", "errore");
|
||||
} finally {
|
||||
$("salvaParametri").disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Calibrazione (logica di #83 e #84; #87: lato precompilato, data italiana, conferma in pagina)
|
||||
function numero(v, cifre) { return Number(v).toFixed(cifre); }
|
||||
|
||||
function righe(d) {
|
||||
return [
|
||||
["Scala", fmt(d.px_per_mm, 4) + " px/mm (" + fmt(d.mm_per_px, 5) + " mm/px)"],
|
||||
["Scala", numero(d.px_per_mm, 4) + " px/mm (" + numero(d.mm_per_px, 5) + " mm/px)"],
|
||||
["Metodo", d.metodo !== "densa" ? "planare (senza correzione della distorsione)" : "densa (immagine raddrizzata)"],
|
||||
["Griglia trovata", d.cols + " x " + d.rows + " angoli (" + fmt(d.n_corners, 0) + ")"]
|
||||
["Griglia trovata", d.cols + " x " + d.rows + " angoli (" + d.n_corners + ")"]
|
||||
].concat(d.metodo !== "densa" ? [
|
||||
["Errore di riproiezione (RMS)", fmt(d.rms_px, 3) + " px"],
|
||||
["Variazione di scala nell'area", fmt(d.scale_spread_pct, 2) + " %"]
|
||||
["Errore di riproiezione (RMS)", numero(d.rms_px, 3) + " px"],
|
||||
["Variazione di scala nell'area", numero(d.scale_spread_pct, 2) + " %"]
|
||||
] : [
|
||||
["Residuo massimo", fmt(d.residuo_max_px, 3) + " px"],
|
||||
["Residuo RMS", fmt(d.residuo_rms_px, 3) + " px"],
|
||||
["Copertura del campo", fmt(d.copertura_pct, 1) + " %"]
|
||||
["Residuo massimo", numero(d.residuo_max_px, 3) + " px"],
|
||||
["Residuo RMS", numero(d.residuo_rms_px, 3) + " px"],
|
||||
["Copertura del campo", numero(d.copertura_pct, 1) + " %"]
|
||||
]).concat([
|
||||
["Lato dello scacco", fmt(d.square_mm) + " mm"],
|
||||
["Immagine", fmt(d.image_width, 0) + " x " + fmt(d.image_height, 0) + " px"]
|
||||
["Lato dello scacco", d.square_mm + " mm"],
|
||||
["Immagine", d.image_width + " x " + d.image_height + " px"]
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -643,45 +123,58 @@ function tabella(el, d) {
|
||||
});
|
||||
}
|
||||
|
||||
async function dettaglio(risposta) {
|
||||
try { var j = await risposta.json(); return j.detail || JSON.stringify(j); }
|
||||
catch (e) { return "errore " + risposta.status; }
|
||||
}
|
||||
|
||||
async function caricaSorgenti() {
|
||||
var sel = $("sorgente");
|
||||
try {
|
||||
var r = await fetch("/camera/sources");
|
||||
var j = await r.json();
|
||||
(j.sources || []).forEach(function (s) {
|
||||
var o = document.createElement("option");
|
||||
o.value = s.name;
|
||||
o.textContent = s.label || s.name;
|
||||
sel.appendChild(o);
|
||||
});
|
||||
} catch (e) { messaggio("Elenco delle sorgenti non disponibile.", "errore"); }
|
||||
if (!sel.options.length) messaggio("Nessuna sorgente camera trovata.", "errore");
|
||||
aggiornaStato();
|
||||
}
|
||||
|
||||
async function aggiornaStato() {
|
||||
var sorgente = $("sorgente").value;
|
||||
var stato = $("stato");
|
||||
annullaConferma();
|
||||
$("elimina").disabled = true;
|
||||
if (!sorgente) { stato.textContent = "-"; $("statoSorgente").textContent = "-"; return; }
|
||||
try {
|
||||
var r = await fetch("/camera/calibration?source=" + encodeURIComponent(sorgente));
|
||||
if (sorgente !== $("sorgente").value) return; // nel frattempo si e' cambiata sorgente
|
||||
if (r.status === 404) {
|
||||
stato.textContent = "Sorgente non calibrata.";
|
||||
$("statoSorgente").textContent = "non calibrata";
|
||||
return;
|
||||
}
|
||||
if (!r.ok) { stato.textContent = await dettaglio(r); return; }
|
||||
var d = await r.json();
|
||||
stato.textContent = "";
|
||||
var t = document.createElement("table");
|
||||
tabella(t, d);
|
||||
var quando = document.createElement("div");
|
||||
quando.textContent = "Calibrata il " + dataItaliana(d.calibrated_at);
|
||||
stato.appendChild(t);
|
||||
stato.appendChild(quando);
|
||||
$("elimina").disabled = false;
|
||||
$("statoSorgente").textContent = "calibrata: " + fmt(d.px_per_mm, 2) + " px/mm";
|
||||
if (d.square_mm) $("quadrato").value = fmtCasella(d.square_mm); // #87: parte dal lato gia' usato
|
||||
} catch (e) {
|
||||
stato.textContent = "Servizio non raggiungibile.";
|
||||
}
|
||||
if (!sorgente) { stato.textContent = "-"; return; }
|
||||
var r = await fetch("/camera/calibration?source=" + encodeURIComponent(sorgente));
|
||||
if (r.status === 404) { stato.textContent = "Sorgente non calibrata."; return; }
|
||||
if (!r.ok) { stato.textContent = await dettaglio(r); return; }
|
||||
var d = await r.json();
|
||||
stato.textContent = "";
|
||||
var t = document.createElement("table");
|
||||
tabella(t, d);
|
||||
var quando = document.createElement("div");
|
||||
quando.textContent = "Calibrata il " + d.calibrated_at;
|
||||
stato.appendChild(t);
|
||||
stato.appendChild(quando);
|
||||
$("elimina").disabled = false;
|
||||
}
|
||||
|
||||
function parametri() {
|
||||
return { quadrato: $("quadrato").value };
|
||||
}
|
||||
|
||||
// avvisi sulla qualita' (solo calibrazione densa): copertura bassa o residuo alto
|
||||
function avvisi(d) {
|
||||
var a = [];
|
||||
if (d.metodo !== "densa") {
|
||||
if (d.scale_spread_pct > 1) a.push("Attenzione: la scala varia di " + fmt(d.scale_spread_pct, 2) + " % nell'area: scacchiera inclinata o ottica non telecentrica.");
|
||||
if (d.scale_spread_pct > 1) a.push("Attenzione: la scala varia di " + numero(d.scale_spread_pct, 2) + " % nell'area: scacchiera inclinata o ottica non telecentrica.");
|
||||
} else {
|
||||
if (d.copertura_pct < 70) a.push("Attenzione: la scacchiera copre solo il " + fmt(d.copertura_pct, 1) + " % del campo (meno del 70 %): la correzione ai bordi non è misurata.");
|
||||
if (d.residuo_max_px > 0.5) a.push("Attenzione: residuo massimo " + fmt(d.residuo_max_px, 3) + " px (oltre 0,5 px): scacchiera non piana, sfocata o con scacchi irregolari.");
|
||||
if (d.copertura_pct < 70) a.push("Attenzione: la scacchiera copre solo il " + numero(d.copertura_pct, 1) + " % del campo (meno del 70 %): la correzione ai bordi non e' misurata.");
|
||||
if (d.residuo_max_px > 0.5) a.push("Attenzione: residuo massimo " + numero(d.residuo_max_px, 3) + " px (oltre 0,5 px): scacchiera non piana, sfocata o con scacchi irregolari.");
|
||||
}
|
||||
return a.join(" ");
|
||||
}
|
||||
@@ -689,19 +182,16 @@ function avvisi(d) {
|
||||
async function calcola(conFile, salva) {
|
||||
var sorgente = $("sorgente").value;
|
||||
if (!sorgente) { messaggio("Scegliere una sorgente.", "errore"); return; }
|
||||
var lato = leggiNumero($("quadrato").value);
|
||||
if (!(lato > 0)) { messaggio("Il lato dello scacco deve essere un numero maggiore di zero.", "errore"); return; }
|
||||
var p = parametri();
|
||||
var f = new FormData();
|
||||
f.append("source", sorgente);
|
||||
f.append("square_mm", String(lato)); // al servizio sempre con il punto
|
||||
f.append("square_mm", p.quadrato);
|
||||
f.append("save", salva ? "true" : "false");
|
||||
if (conFile) {
|
||||
var file = $("file").files[0];
|
||||
if (!file) { messaggio("Scegliere un file immagine.", "errore"); return; }
|
||||
f.append("image", file);
|
||||
}
|
||||
var eraAttiva = A.attiva; // #87: l'anteprima si sospende e poi riprende
|
||||
if (eraAttiva) fermaAnteprima();
|
||||
messaggio("Calcolo in corso...", "");
|
||||
["acquisisci", "carica", "salva"].forEach(function (i) { $(i).disabled = true; });
|
||||
try {
|
||||
@@ -719,7 +209,6 @@ async function calcola(conFile, salva) {
|
||||
messaggio("Servizio non raggiungibile.", "errore");
|
||||
} finally {
|
||||
["acquisisci", "carica", "salva"].forEach(function (i) { $(i).disabled = false; });
|
||||
if (eraAttiva) avviaAnteprima();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -727,37 +216,14 @@ $("acquisisci").onclick = function () { calcola(false, false); };
|
||||
$("carica").onclick = function () { calcola(true, false); };
|
||||
// «Salva» rifa il calcolo con save=true sulla stessa origine (altra acquisizione se da camera)
|
||||
$("salva").onclick = function () { calcola(ultimo ? ultimo.conFile : false, true); };
|
||||
// #87: il pulsante del file e' in italiano, l'input nativo resta nascosto
|
||||
$("scegliFile").onclick = function () { $("file").click(); };
|
||||
$("file").onchange = function () {
|
||||
$("nomeFile").textContent = $("file").files[0] ? $("file").files[0].name : "nessuna immagine scelta";
|
||||
};
|
||||
|
||||
// #87: «Elimina calibrazione» chiede conferma in pagina con un secondo clic (niente finestre del browser)
|
||||
var confermaTimer = null;
|
||||
function annullaConferma() {
|
||||
clearTimeout(confermaTimer);
|
||||
confermaTimer = null;
|
||||
$("elimina").textContent = "Elimina calibrazione";
|
||||
}
|
||||
$("elimina").onclick = async function () {
|
||||
if (!confermaTimer) {
|
||||
$("elimina").textContent = "Confermi? Clic ancora per eliminare";
|
||||
confermaTimer = setTimeout(annullaConferma, 4000);
|
||||
return;
|
||||
}
|
||||
annullaConferma();
|
||||
var sorgente = $("sorgente").value;
|
||||
try {
|
||||
var r = await fetch("/camera/calibration?source=" + encodeURIComponent(sorgente), { method: "DELETE" });
|
||||
messaggio(r.ok ? "Calibrazione eliminata." : await dettaglio(r), r.ok ? "ok" : "errore");
|
||||
} catch (e) {
|
||||
messaggio("Servizio non raggiungibile.", "errore");
|
||||
}
|
||||
if (!confirm("Eliminare la calibrazione salvata di " + sorgente + "?")) return;
|
||||
var r = await fetch("/camera/calibration?source=" + encodeURIComponent(sorgente), { method: "DELETE" });
|
||||
messaggio(r.ok ? "Calibrazione eliminata." : await dettaglio(r), r.ok ? "ok" : "errore");
|
||||
aggiornaStato();
|
||||
};
|
||||
|
||||
$("sorgente").onchange = function () { cambioSorgente(true); };
|
||||
$("sorgente").onchange = aggiornaStato;
|
||||
caricaSorgenti();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
+171
-10
@@ -26,6 +26,23 @@ Calibrazione a scacchiera per sorgente (#83, logica in scripts/calibrazione.py):
|
||||
422 scacchiera non trovata o parametri invalidi
|
||||
DELETE /camera/calibration?source=<src> -> 204 (toglie JSON e mappa)
|
||||
GET /calibrazione -> pagina HTML autonoma (offline) per fare tutto a mano
|
||||
GET /acquisizione -> stessa pagina (#87)
|
||||
Acquisizione (#87): anteprima, esposizione/guadagno, settaggi di default:
|
||||
POST /camera/anteprima json {source, grezzo=false, lato_max=1280 (320..4096),
|
||||
timeout_ms=5000} -> JPEG ridotto (lato lungo <= lato_max, mai ingrandito).
|
||||
Header: X-Raddrizzato si|no, X-Larghezza/X-Altezza (frame PIENO),
|
||||
X-Nitidezza (varianza del Laplaciano), X-Saturati-Pct (% pixel >= 250),
|
||||
X-Media (livello medio 0-255), tutti sul frame pieno in grigio.
|
||||
GET /camera/parametri?source=<src> -> {source, regolabile, parametri:
|
||||
{esposizione, esposizione_auto, guadagno, guadagno_auto: dominio}};
|
||||
dominio = GenicamSource.dominio (valore, tipo, min/max/passo o scelte).
|
||||
Esposizione in us, guadagno in dB. Sorgente senza capacita': regolabile=false.
|
||||
PUT /camera/parametri json {source, valori: {nome: valore}} -> come GET.
|
||||
Scrive prima gli *_auto, poi esposizione/guadagno (assesta=3).
|
||||
400 nome ignoto o corpo errato, 409 non regolabile, 422 camera rifiuta
|
||||
POST /camera/parametri/salva json {source} -> {source, salvati, file}
|
||||
scrive <config-dir>/settaggi/<nome>-<sha1>.json; alla prima apertura
|
||||
della camera nel servizio il file, se c'e', viene ricaricato.
|
||||
Il risultato salvato sta in un JSON per sorgente in --config-dir (default
|
||||
%ProgramData%/Tielogic/TieMeasureFlow/acquisitore su Windows, ./acquisitore altrove).
|
||||
|
||||
@@ -49,6 +66,7 @@ camera a chiunque raggiunga la porta: solo su rete isolata.
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
@@ -61,7 +79,7 @@ from fastapi import Body, FastAPI, File, Form, HTTPException, UploadFile
|
||||
from fastapi.concurrency import run_in_threadpool # #83: il grab bloccante fuori dall'event loop
|
||||
from fastapi.responses import HTMLResponse, Response
|
||||
|
||||
from visionsuite.camera.camera_interface import CameraError, CameraGrabError
|
||||
from visionsuite.camera.camera_interface import CameraError, CameraGrabError, CameraStateError
|
||||
from visionsuite.camera.sources import SourceNotFound, list_sources, open_source
|
||||
|
||||
# #83: calibrazione e pagina stanno accanto a questo file (che non e' un pacchetto)
|
||||
@@ -70,6 +88,11 @@ import calibrazione # noqa: E402
|
||||
from calibrazione_pagina import PAGINA # noqa: E402
|
||||
|
||||
_TRIGGER = ("continuo",)
|
||||
_log = logging.getLogger("camera_service") # #87: errori non bloccanti (settaggi)
|
||||
# #87: i quattro parametri regolabili, nell'ordine di presentazione
|
||||
_PARAMETRI = ("esposizione", "esposizione_auto", "guadagno", "guadagno_auto")
|
||||
# #87: in scrittura prima gli automatismi (un valore manuale con auto acceso viene riscritto)
|
||||
_ORDINE_SCRITTURA = ("esposizione_auto", "guadagno_auto", "esposizione", "guadagno")
|
||||
|
||||
# errori noti di vs-camera (inglese) -> italiano; esatti, poi per prefisso
|
||||
_MESSAGGI = {
|
||||
@@ -140,17 +163,35 @@ def create_app(folder: Path | str | None = None,
|
||||
return {"sources": [{"name": v.name, "kind": v.kind, "label": v.label}
|
||||
for v in voci if v.kind in ("camera", "virtual")]}
|
||||
|
||||
def _grab(nome: str, timeout: int):
|
||||
"""Un frame dalla sorgente (apre e tiene aperta); errori camera -> HTTP."""
|
||||
def _apri(nome: str):
|
||||
"""#87: sorgente aperta (o da aprire); da chiamare SOLO sotto _lock_di(nome)."""
|
||||
with lock:
|
||||
sorgente = aperte.get(nome)
|
||||
if sorgente is None:
|
||||
sorgente = open_source(nome, None, config=_config())
|
||||
_carica_settaggi(nome, sorgente) # #87: predefiniti salvati, se ci sono
|
||||
with lock:
|
||||
aperte[nome] = sorgente
|
||||
return sorgente
|
||||
|
||||
def _file_settaggi(nome: str) -> Path:
|
||||
# #87: stesso schema di nome della calibrazione, in una sottocartella
|
||||
return archivio.cartella / "settaggi" / archivio._file(nome).name
|
||||
|
||||
def _carica_settaggi(nome: str, sorgente) -> None:
|
||||
percorso = _file_settaggi(nome)
|
||||
if not percorso.is_file() or not hasattr(sorgente, "carica_settaggi"):
|
||||
return
|
||||
try:
|
||||
sorgente.carica_settaggi(percorso)
|
||||
except Exception as e: # noqa: BLE001 - un file rotto non blocca l'apertura
|
||||
_log.warning("settaggi di %s non caricati (%s): %s", nome, percorso, e)
|
||||
|
||||
def _con_sorgente(nome: str, fn):
|
||||
"""#87: fn(sorgente) sotto il lock della sorgente; errori camera -> HTTP."""
|
||||
try:
|
||||
with _lock_di(nome): # solo questa sorgente; le altre non aspettano
|
||||
with lock:
|
||||
sorgente = aperte.get(nome)
|
||||
if sorgente is None:
|
||||
sorgente = open_source(nome, None, config=_config())
|
||||
with lock:
|
||||
aperte[nome] = sorgente
|
||||
return sorgente.grab(timeout)
|
||||
return fn(_apri(nome))
|
||||
except SourceNotFound as e:
|
||||
raise HTTPException(404, _traduci(str(e)))
|
||||
except TimeoutError as e:
|
||||
@@ -158,6 +199,10 @@ def create_app(folder: Path | str | None = None,
|
||||
except (CameraGrabError, CameraError) as e:
|
||||
raise HTTPException(503, _traduci(str(e)))
|
||||
|
||||
def _grab(nome: str, timeout: int):
|
||||
"""Un frame dalla sorgente (apre e tiene aperta); errori camera -> HTTP."""
|
||||
return _con_sorgente(nome, lambda s: s.grab(timeout)) # #87: corpo in _con_sorgente
|
||||
|
||||
@app.post("/camera/grab")
|
||||
def camera_grab(corpo: dict = Body(default=None)):
|
||||
nome = corpo.get("source") if isinstance(corpo, dict) else None
|
||||
@@ -241,7 +286,123 @@ def create_app(folder: Path | str | None = None,
|
||||
archivio.salva(nome, corpo, mappa) # #84: la mappa (se densa) va in .npz accanto
|
||||
return {**corpo, "overlay_png": risultato["overlay_png"], "saved": bool(save)}
|
||||
|
||||
# ---- #87: acquisizione (anteprima, parametri, settaggi)
|
||||
|
||||
def _regolabile(s) -> bool:
|
||||
# #87: ha il vocabolario delle capacita' e ne supporta almeno una delle quattro
|
||||
if not (hasattr(s, "supporta") and hasattr(s, "dominio")):
|
||||
return False
|
||||
return any(s.supporta(n) for n in _PARAMETRI)
|
||||
|
||||
def _parametri(nome: str, s) -> dict:
|
||||
"""#87: forma di GET /camera/parametri; da chiamare sotto lock."""
|
||||
parametri: dict = {}
|
||||
if _regolabile(s):
|
||||
for n in _PARAMETRI:
|
||||
if not s.supporta(n):
|
||||
continue
|
||||
try:
|
||||
parametri[n] = s.dominio(n)
|
||||
except CameraStateError as e: # capacita' dichiarata ma non leggibile: si salta
|
||||
_log.warning("dominio di %s su %s non letto: %s", n, nome, e)
|
||||
return {"source": nome, "regolabile": bool(parametri), "parametri": parametri}
|
||||
|
||||
def _grigio8(frame):
|
||||
g = frame if frame.ndim == 2 else cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
||||
if g.dtype == np.uint16: # #87: 16 bit -> 8 bit, per soglia e media coerenti
|
||||
g = (g >> 8).astype(np.uint8)
|
||||
elif g.dtype != np.uint8:
|
||||
g = cv2.convertScaleAbs(g, alpha=255.0 / max(float(g.max()), 1.0))
|
||||
return g
|
||||
|
||||
@app.post("/camera/anteprima")
|
||||
def camera_anteprima(corpo: dict = Body(default=None)):
|
||||
corpo = corpo if isinstance(corpo, dict) else {}
|
||||
nome = _sorgente(corpo.get("source"))
|
||||
grezzo = corpo.get("grezzo", False)
|
||||
if not isinstance(grezzo, bool):
|
||||
raise HTTPException(400, "«grezzo» deve essere true o false")
|
||||
lato = corpo.get("lato_max", 1280)
|
||||
if isinstance(lato, bool) or not isinstance(lato, int) or not 320 <= lato <= 4096:
|
||||
raise HTTPException(400, "«lato_max» deve essere un intero fra 320 e 4096")
|
||||
timeout = corpo.get("timeout_ms", 5000)
|
||||
if isinstance(timeout, bool) or not isinstance(timeout, int) or not 1 <= timeout <= 10000:
|
||||
raise HTTPException(400, "«timeout_ms» deve essere un intero fra 1 e 10000")
|
||||
frame = _grab(nome, timeout)
|
||||
raddrizzato = False
|
||||
if not grezzo: # come /camera/grab
|
||||
mappa = archivio.leggi_mappa(nome)
|
||||
if mappa is not None:
|
||||
corretto = calibrazione.raddrizza(frame, mappa)
|
||||
if corretto is not None:
|
||||
frame, raddrizzato = corretto, True
|
||||
alt, larg = frame.shape[:2]
|
||||
grigio = _grigio8(frame) # misure sul frame PIENO, non sul ridotto
|
||||
nitidezza = float(cv2.Laplacian(grigio, cv2.CV_64F).var())
|
||||
saturati = float((grigio >= 250).mean() * 100.0)
|
||||
media = float(grigio.mean())
|
||||
if frame.dtype != np.uint8: # il JPEG vuole 8 bit
|
||||
frame = grigio
|
||||
scala = lato / max(alt, larg)
|
||||
if scala < 1.0: # mai ingrandita
|
||||
frame = cv2.resize(frame, (max(1, round(larg * scala)), max(1, round(alt * scala))),
|
||||
interpolation=cv2.INTER_AREA)
|
||||
ok, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
||||
if not ok:
|
||||
raise HTTPException(500, "codifica JPEG fallita")
|
||||
return Response(buf.tobytes(), media_type="image/jpeg", headers={
|
||||
"X-Raddrizzato": "si" if raddrizzato else "no",
|
||||
"X-Larghezza": str(larg), "X-Altezza": str(alt),
|
||||
"X-Nitidezza": f"{nitidezza:.1f}", "X-Saturati-Pct": f"{saturati:.2f}",
|
||||
"X-Media": f"{media:.1f}"})
|
||||
|
||||
@app.get("/camera/parametri")
|
||||
def parametri_leggi(source: str = ""):
|
||||
nome = _sorgente(source)
|
||||
return _con_sorgente(nome, lambda s: _parametri(nome, s))
|
||||
|
||||
@app.put("/camera/parametri")
|
||||
def parametri_scrivi(corpo: dict = Body(default=None)):
|
||||
corpo = corpo if isinstance(corpo, dict) else {}
|
||||
nome = _sorgente(corpo.get("source"))
|
||||
valori = corpo.get("valori")
|
||||
if not isinstance(valori, dict) or not valori:
|
||||
raise HTTPException(400, "manca «valori»")
|
||||
for n, v in valori.items():
|
||||
if n not in _PARAMETRI:
|
||||
raise HTTPException(400, f"parametro non riconosciuto: «{n}»")
|
||||
if isinstance(v, bool) or not isinstance(v, (int, float, str)):
|
||||
raise HTTPException(400, f"valore non valido per «{n}»")
|
||||
|
||||
def scrivi(s):
|
||||
if not _regolabile(s):
|
||||
raise HTTPException(409, "questa sorgente non ha parametri regolabili")
|
||||
for n in _ORDINE_SCRITTURA: # prima gli *_auto
|
||||
if n in valori:
|
||||
try:
|
||||
s.scrivi(n, valori[n], assesta=3)
|
||||
except CameraStateError as e:
|
||||
raise HTTPException(422, _traduci(str(e)))
|
||||
return _parametri(nome, s) # valori riletti dalla camera
|
||||
|
||||
return _con_sorgente(nome, scrivi)
|
||||
|
||||
@app.post("/camera/parametri/salva")
|
||||
def parametri_salva(corpo: dict = Body(default=None)):
|
||||
nome = _sorgente(corpo.get("source") if isinstance(corpo, dict) else None)
|
||||
|
||||
def salva(s):
|
||||
if not _regolabile(s) or not hasattr(s, "salva_settaggi"):
|
||||
raise HTTPException(409, "questa sorgente non ha parametri regolabili")
|
||||
percorso = _file_settaggi(nome)
|
||||
percorso.parent.mkdir(parents=True, exist_ok=True)
|
||||
s.salva_settaggi(percorso)
|
||||
return {"source": nome, "salvati": True, "file": percorso.name}
|
||||
|
||||
return _con_sorgente(nome, salva)
|
||||
|
||||
@app.get("/calibrazione", response_class=HTMLResponse)
|
||||
@app.get("/acquisizione", response_class=HTMLResponse) # #87: stessa pagina
|
||||
def calibrazione_pagina():
|
||||
return HTMLResponse(PAGINA)
|
||||
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
"""Test delle rotte di acquisizione di scripts/camera_service.py (#87).
|
||||
|
||||
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
|
||||
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
|
||||
Copre: POST /camera/anteprima, GET/PUT /camera/parametri, POST /camera/parametri/salva,
|
||||
caricamento dei settaggi alla riapertura, alias GET /acquisizione.
|
||||
Sorgente regolabile finta (iniettata su open_source) e FolderCamera su PNG temporanei.
|
||||
"""
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from visionsuite.camera.camera_interface import CameraStateError
|
||||
|
||||
MODULO = Path(__file__).resolve().parents[1] / "camera_service.py"
|
||||
SORGENTE = "camera:finta"
|
||||
|
||||
|
||||
def _carica():
|
||||
spec = importlib.util.spec_from_file_location("camera_service_acq", MODULO)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
class Finta:
|
||||
"""Camera regolabile finta: stesso vocabolario di GenicamSource."""
|
||||
|
||||
DOMINI = {
|
||||
"esposizione": {"tipo": "numero", "min": 10.0, "max": 100000.0, "passo": 1.0},
|
||||
"esposizione_auto": {"tipo": "scelta", "scelte": ["Off", "Once", "Continuous"]},
|
||||
"guadagno": {"tipo": "numero", "min": 0.0, "max": 24.0},
|
||||
"guadagno_auto": {"tipo": "scelta", "scelte": ["Off", "Once", "Continuous"]},
|
||||
}
|
||||
|
||||
def __init__(self, supportati=None, rifiuta=()):
|
||||
self.valori = {"esposizione": 5000.0, "esposizione_auto": "Continuous",
|
||||
"guadagno": 1.0, "guadagno_auto": "Off"}
|
||||
self.supportati = set(supportati if supportati is not None else self.DOMINI)
|
||||
self.rifiuta = set(rifiuta)
|
||||
self.scritture = []
|
||||
self.caricato = None
|
||||
self.chiusa = False
|
||||
# metà bianca (255) e metà a 100: saturati 50%, media 177,5
|
||||
frame = np.full((1000, 2000, 3), 100, np.uint8)
|
||||
frame[:, :1000] = 255
|
||||
self.frame = frame
|
||||
|
||||
def supporta(self, nome):
|
||||
return nome in self.supportati
|
||||
|
||||
def dominio(self, nome):
|
||||
return {"capacita": nome, "valore": self.valori[nome], **self.DOMINI[nome]}
|
||||
|
||||
def leggi(self, nome):
|
||||
return self.valori[nome]
|
||||
|
||||
def scrivi(self, nome, valore, assesta=0):
|
||||
if nome in self.rifiuta:
|
||||
raise CameraStateError(f"non posso impostare {nome}: nodo in sola lettura")
|
||||
self.scritture.append((nome, valore, assesta))
|
||||
self.valori[nome] = valore
|
||||
|
||||
def salva_settaggi(self, percorso):
|
||||
Path(percorso).write_text(json.dumps(self.valori), encoding="utf-8")
|
||||
return dict(self.valori)
|
||||
|
||||
def carica_settaggi(self, percorso, forza=False):
|
||||
self.caricato = Path(percorso)
|
||||
self.valori.update(json.loads(self.caricato.read_text(encoding="utf-8")))
|
||||
return []
|
||||
|
||||
def grab(self, timeout=0):
|
||||
return self.frame.copy()
|
||||
|
||||
def close(self):
|
||||
self.chiusa = True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cartella(tmp_path):
|
||||
img = tmp_path / "foto"
|
||||
img.mkdir()
|
||||
for nome in ("a.png", "b.png"):
|
||||
cv2.imwrite(str(img / nome), np.full((48, 64, 3), 128, np.uint8))
|
||||
return img
|
||||
|
||||
|
||||
def _client(cartella, config, monkeypatch, finta):
|
||||
mod = _carica()
|
||||
monkeypatch.setattr(mod, "open_source", lambda *a, **k: finta)
|
||||
c = TestClient(mod.create_app(cartella, config))
|
||||
c.mod = mod
|
||||
return c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def finta():
|
||||
return Finta()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(cartella, tmp_path, monkeypatch, finta):
|
||||
with _client(cartella, tmp_path / "cfg", monkeypatch, finta) as c:
|
||||
c.finta = finta
|
||||
c.config = tmp_path / "cfg"
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_cartella(cartella, tmp_path):
|
||||
mod = _carica()
|
||||
with TestClient(mod.create_app(cartella, tmp_path / "cfg")) as c:
|
||||
yield c
|
||||
|
||||
|
||||
# ---- anteprima
|
||||
|
||||
def test_anteprima_jpeg_ridotta_e_header(client):
|
||||
r = client.post("/camera/anteprima", json={"source": SORGENTE, "lato_max": 500})
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"] == "image/jpeg"
|
||||
img = cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_COLOR)
|
||||
assert img.shape[:2] == (250, 500) # lato lungo 2000 -> 500
|
||||
assert r.headers["x-raddrizzato"] == "no"
|
||||
assert (r.headers["x-larghezza"], r.headers["x-altezza"]) == ("2000", "1000") # frame pieno
|
||||
assert r.headers["x-saturati-pct"] == "50.00"
|
||||
assert r.headers["x-media"] == "177.5"
|
||||
assert float(r.headers["x-nitidezza"]) > 0 # bordo netto fra le due metà
|
||||
|
||||
|
||||
def test_anteprima_mai_ingrandita(client_cartella):
|
||||
r = client_cartella.post("/camera/anteprima", json={"source": "camera:folder"})
|
||||
assert r.status_code == 200
|
||||
img = cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_COLOR)
|
||||
assert img.shape[:2] == (48, 64) # lato_max 1280 > 64: resta com'è
|
||||
assert r.headers["x-nitidezza"] == "0.0" # grigio uniforme
|
||||
assert r.headers["x-saturati-pct"] == "0.00"
|
||||
assert r.headers["x-media"] == "128.0"
|
||||
|
||||
|
||||
def test_anteprima_parametri_errati_400(client):
|
||||
for corpo in ({}, {"source": SORGENTE, "lato_max": 100}, {"source": SORGENTE, "lato_max": 5000},
|
||||
{"source": SORGENTE, "timeout_ms": 0}, {"source": SORGENTE, "grezzo": "si"}):
|
||||
assert client.post("/camera/anteprima", json=corpo).status_code == 400, corpo
|
||||
|
||||
|
||||
def test_anteprima_sorgente_inesistente_404(client_cartella):
|
||||
assert client_cartella.post("/camera/anteprima",
|
||||
json={"source": "camera:non-esiste"}).status_code == 404
|
||||
|
||||
|
||||
def test_anteprima_errore_camera_503_e_timeout_504(client):
|
||||
from visionsuite.camera.camera_interface import CameraGrabError
|
||||
|
||||
def rotta(timeout=0):
|
||||
raise CameraGrabError("Camera is not open")
|
||||
|
||||
client.finta.grab = rotta
|
||||
r = client.post("/camera/anteprima", json={"source": SORGENTE})
|
||||
assert r.status_code == 503 and r.json()["detail"] == "La camera non è aperta"
|
||||
|
||||
def lenta(timeout=0):
|
||||
raise TimeoutError("scaduto")
|
||||
|
||||
client.finta.grab = lenta
|
||||
assert client.post("/camera/anteprima", json={"source": SORGENTE}).status_code == 504
|
||||
|
||||
|
||||
# ---- parametri
|
||||
|
||||
def test_parametri_regolabile(client):
|
||||
r = client.get("/camera/parametri", params={"source": SORGENTE})
|
||||
assert r.status_code == 200
|
||||
j = r.json()
|
||||
assert j["source"] == SORGENTE and j["regolabile"] is True
|
||||
assert list(j["parametri"]) == ["esposizione", "esposizione_auto", "guadagno", "guadagno_auto"]
|
||||
assert j["parametri"]["esposizione"]["valore"] == 5000.0
|
||||
assert j["parametri"]["esposizione_auto"]["scelte"] == ["Off", "Once", "Continuous"]
|
||||
|
||||
|
||||
def test_parametri_solo_quelli_supportati(cartella, tmp_path, monkeypatch):
|
||||
with _client(cartella, tmp_path / "cfg", monkeypatch, Finta(supportati=["esposizione"])) as c:
|
||||
j = c.get("/camera/parametri", params={"source": SORGENTE}).json()
|
||||
assert j["regolabile"] is True and list(j["parametri"]) == ["esposizione"]
|
||||
|
||||
|
||||
def test_parametri_sorgente_non_regolabile(client_cartella):
|
||||
j = client_cartella.get("/camera/parametri", params={"source": "camera:folder"}).json()
|
||||
assert j == {"source": "camera:folder", "regolabile": False, "parametri": {}}
|
||||
|
||||
|
||||
def test_parametri_senza_source_400(client):
|
||||
assert client.get("/camera/parametri").status_code == 400
|
||||
|
||||
|
||||
def test_put_scrive_prima_gli_auto(client):
|
||||
r = client.put("/camera/parametri", json={"source": SORGENTE, "valori": {
|
||||
"esposizione": 8000, "guadagno": 3.5, "esposizione_auto": "Off", "guadagno_auto": "Off"}})
|
||||
assert r.status_code == 200
|
||||
assert client.finta.scritture == [
|
||||
("esposizione_auto", "Off", 3), ("guadagno_auto", "Off", 3),
|
||||
("esposizione", 8000, 3), ("guadagno", 3.5, 3)]
|
||||
j = r.json() # valori riletti dalla camera
|
||||
assert j["parametri"]["esposizione"]["valore"] == 8000
|
||||
assert j["parametri"]["esposizione_auto"]["valore"] == "Off"
|
||||
|
||||
|
||||
def test_put_nome_ignoto_400(client):
|
||||
r = client.put("/camera/parametri", json={"source": SORGENTE, "valori": {"frame_rate": 10}})
|
||||
assert r.status_code == 400
|
||||
assert client.finta.scritture == []
|
||||
for corpo in ({"source": SORGENTE}, {"source": SORGENTE, "valori": {}},
|
||||
{"source": SORGENTE, "valori": {"guadagno": True}}):
|
||||
assert client.put("/camera/parametri", json=corpo).status_code == 400, corpo
|
||||
|
||||
|
||||
def test_put_non_regolabile_409(client_cartella):
|
||||
r = client_cartella.put("/camera/parametri",
|
||||
json={"source": "camera:folder", "valori": {"esposizione": 1000}})
|
||||
assert r.status_code == 409
|
||||
assert r.json()["detail"] == "questa sorgente non ha parametri regolabili"
|
||||
|
||||
|
||||
def test_put_rifiuto_della_camera_422(cartella, tmp_path, monkeypatch):
|
||||
with _client(cartella, tmp_path / "cfg", monkeypatch, Finta(rifiuta=["guadagno"])) as c:
|
||||
r = c.put("/camera/parametri", json={"source": SORGENTE, "valori": {"guadagno": 5}})
|
||||
assert r.status_code == 422
|
||||
assert "guadagno" in r.json()["detail"]
|
||||
|
||||
|
||||
# ---- salvataggio e riapertura
|
||||
|
||||
def test_salva_crea_il_file(client):
|
||||
r = client.post("/camera/parametri/salva", json={"source": SORGENTE})
|
||||
assert r.status_code == 200
|
||||
j = r.json()
|
||||
assert j["source"] == SORGENTE and j["salvati"] is True
|
||||
atteso = client.config / "settaggi" / j["file"]
|
||||
assert atteso.is_file()
|
||||
assert j["file"].startswith("camera_finta-") and j["file"].endswith(".json")
|
||||
assert json.loads(atteso.read_text(encoding="utf-8"))["esposizione"] == 5000.0
|
||||
|
||||
|
||||
def test_salva_non_regolabile_409(client_cartella):
|
||||
r = client_cartella.post("/camera/parametri/salva", json={"source": "camera:folder"})
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
def test_riapertura_carica_i_settaggi(cartella, tmp_path, monkeypatch):
|
||||
config = tmp_path / "cfg"
|
||||
prima = Finta()
|
||||
with _client(cartella, config, monkeypatch, prima) as c:
|
||||
c.put("/camera/parametri", json={"source": SORGENTE, "valori": {"esposizione": 7777}})
|
||||
file = c.post("/camera/parametri/salva", json={"source": SORGENTE}).json()["file"]
|
||||
seconda = Finta() # servizio riavviato: camera nuova, valori di fabbrica
|
||||
with _client(cartella, config, monkeypatch, seconda) as c:
|
||||
assert seconda.caricato is None # niente apertura finché nessuno la chiede
|
||||
c.get("/camera/parametri", params={"source": SORGENTE})
|
||||
assert seconda.caricato == config / "settaggi" / file
|
||||
assert seconda.valori["esposizione"] == 7777
|
||||
|
||||
|
||||
def test_settaggi_rotti_non_bloccano_l_apertura(cartella, tmp_path, monkeypatch):
|
||||
class Rotta(Finta):
|
||||
def carica_settaggi(self, percorso, forza=False):
|
||||
raise ValueError("file illeggibile")
|
||||
|
||||
config = tmp_path / "cfg"
|
||||
with _client(cartella, config, monkeypatch, Finta()) as c:
|
||||
file = c.post("/camera/parametri/salva", json={"source": SORGENTE}).json()["file"]
|
||||
assert (config / "settaggi" / file).is_file()
|
||||
with _client(cartella, config, monkeypatch, Rotta()) as c:
|
||||
assert c.get("/camera/parametri", params={"source": SORGENTE}).status_code == 200
|
||||
|
||||
|
||||
# ---- pagina
|
||||
|
||||
def test_acquisizione_alias_della_pagina(client):
|
||||
a = client.get("/acquisizione")
|
||||
c = client.get("/calibrazione")
|
||||
assert a.status_code == 200 and a.text == c.text
|
||||
@@ -1,54 +0,0 @@
|
||||
"""Test della pagina Acquisizione camera (#87): contenuto statico di PAGINA, senza servizio."""
|
||||
|
||||
import importlib.util
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
_FILE = Path(__file__).resolve().parents[1] / "calibrazione_pagina.py"
|
||||
_spec = importlib.util.spec_from_file_location("calibrazione_pagina", _FILE)
|
||||
_mod = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_mod)
|
||||
PAGINA = _mod.PAGINA
|
||||
|
||||
|
||||
def test_titolo_e_stile_scuro():
|
||||
assert "<title>Acquisizione camera</title>" in PAGINA
|
||||
assert "#0f172a" in PAGINA and "#1e293b" in PAGINA and "#3b82f6" in PAGINA
|
||||
|
||||
|
||||
def test_rotte_usate():
|
||||
for rotta in ("/camera/sources", "/camera/anteprima", "/camera/parametri",
|
||||
"/camera/parametri/salva", "/camera/calibrate", "/camera/calibration"):
|
||||
assert '"' + rotta in PAGINA, rotta
|
||||
|
||||
|
||||
def test_offline_e_senza_finestre_del_browser():
|
||||
for vietato in ("alert(", "confirm(", "prompt(", "http://", "https://"):
|
||||
assert vietato not in PAGINA, vietato
|
||||
|
||||
|
||||
def test_id_principali():
|
||||
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
|
||||
attesi = {"sorgente", "avvia", "ferma", "scatta", "grezzo", "mirinoCasella", "immagine",
|
||||
"nitidezza", "nitidezzaMax", "saturati", "media", "dimensioni",
|
||||
"parametri", "salvaParametri", "quadrato", "acquisisci", "scegliFile", "file",
|
||||
"carica", "salva", "elimina", "stato", "statoSorgente"}
|
||||
assert attesi <= ids, attesi - ids
|
||||
# ogni id usato dal JS con $("...") esiste nel markup
|
||||
usati = set(re.findall(r'\$\("([^"]+)"\)', PAGINA))
|
||||
assert usati <= ids, usati - ids
|
||||
|
||||
|
||||
def test_sezioni_e_testi_italiani():
|
||||
for testo in ("Sorgente", "Anteprima", "Parametri camera", "Calibrazione",
|
||||
"Salva come predefiniti", "Scegli immagine...", "Questa sorgente non ha parametri regolabili",
|
||||
"Immagine grezza (senza correzione)"):
|
||||
assert testo in PAGINA, testo
|
||||
assert "it-IT" in PAGINA # numeri con la virgola
|
||||
assert "Choose File" not in PAGINA
|
||||
|
||||
|
||||
def test_localstorage_protetto():
|
||||
# ogni uso di localStorage sta dentro un try
|
||||
for m in re.finditer(r"localStorage[.]", PAGINA):
|
||||
assert "try {" in PAGINA[max(0, m.start() - 200):m.start()]
|
||||
Reference in New Issue
Block a user