800ec41254
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1244 lines
56 KiB
Python
1244 lines
56 KiB
Python
"""Pagina HTML dell'acquisizione camera (servita da camera_service su /calibrazione e /acquisizione, #83 #87).
|
||
|
||
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.
|
||
#91: due tab, «Acquisizione» e «Calibrazione», nella stessa pagina; l'anteprima resta
|
||
visibile in entrambe. Calibrazioni salvate con nome in un archivio unico
|
||
(/camera/calibrazioni, /camera/calibrazione-attiva); il calcolo non salva piu':
|
||
si salva dal calcolo_id con un nome.
|
||
#92: l'immagine da file e' una SORGENTE di acquisizione (POST /camera/immagini), non
|
||
un'opzione della calibrazione: la calibrazione usa sempre il frame grezzo della sorgente
|
||
scelta nella tab Acquisizione. Sorgente in cima alla tab; riquadri «Immagine» (grezza) e,
|
||
con la casella, «Calibrata» (/camera/anteprima/calibrata) affiancati; il ciclo anteprima
|
||
si sospende mentre la tab Calibrazione e' aperta.
|
||
Dipende da: niente. E' una stringa, cosi' il pacchetto compilato non ha file dati.
|
||
"""
|
||
|
||
PAGINA = r"""<!doctype html>
|
||
<html lang="it">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Acquisizione 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); }
|
||
.scelta-cal { margin-top: 8px; align-items: center; } /* #93 */
|
||
.scelta-cal select { flex: 1 1 200px; min-width: 0; } /* #93: nessuno scorrimento a 400 px */
|
||
.riga { display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-end; }
|
||
.nota { font-size: .82rem; color: var(--secondario); margin: 8px 0 0; }
|
||
button { padding: 7px 13px; font-size: .95rem; border: 0; border-radius: 6px; background: var(--accento); color: #fff;
|
||
cursor: pointer; font-family: inherit; }
|
||
button.secondario { background: var(--bordo); color: var(--testo); }
|
||
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; /* #92: classe, vale per i due riquadri */
|
||
background: linear-gradient(#fbbf24, #fbbf24) center / 2px 100% no-repeat,
|
||
linear-gradient(#fbbf24, #fbbf24) center / 100% 2px no-repeat; opacity: .85; }
|
||
#vuoto, #vuotoCal { padding: 30px 16px; color: var(--secondario); text-align: center; }
|
||
/* #92: due riquadri affiancati (grezza | calibrata); in colonna sotto i 900 px */
|
||
.riquadri { display: grid; grid-template-columns: minmax(0, 1fr); gap: 14px; margin-top: 10px; }
|
||
.riquadri.doppio { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
|
||
.riquadro .titolo { font-size: .88rem; font-weight: 600; }
|
||
.riquadro .titolo span { font-weight: 400; font-size: .78rem; color: var(--secondario); margin-left: 6px; }
|
||
.riquadro .palco { margin-top: 4px; }
|
||
.sorgente-riga { display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-end; }
|
||
.sorgente-riga .campo-sorgente { flex: 1 1 260px; min-width: 0; }
|
||
.sorgente-riga select { width: 100%; }
|
||
.indicatori { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin-top: 10px; }
|
||
.indicatore { background: var(--sfondo); border: 1px solid var(--bordo); border-radius: 6px; padding: 8px 10px; }
|
||
.indicatore .nome { font-size: .78rem; color: var(--secondario); }
|
||
.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); }
|
||
/* #91: tab Acquisizione | Calibrazione */
|
||
.tabs { display: flex; gap: 6px; margin: 0 0 14px; border-bottom: 1px solid var(--bordo); }
|
||
.tabs button { background: transparent; color: var(--secondario); border-radius: 6px 6px 0 0; border-bottom: 3px solid transparent;
|
||
flex: 0 1 auto; padding: 8px 18px; }
|
||
.tabs button[aria-selected=true] { color: var(--testo); background: var(--pannello); border-bottom-color: var(--accento); font-weight: 600; }
|
||
/* #91: calibrazione con nome, elenco delle salvate */
|
||
/* #91: elenco a schede, una per calibrazione; nome e risoluzione sempre visibili, niente scorrimento orizzontale */
|
||
.elenco { margin-top: 8px; }
|
||
.scheda-cal { border-top: 1px solid var(--bordo); padding: 8px 0; }
|
||
.scheda-cal .nome-cal { font-weight: 700; overflow-wrap: anywhere; }
|
||
.scheda-cal .dettagli { font-size: .8rem; color: var(--secondario); margin-top: 2px; overflow-wrap: anywhere; }
|
||
.scheda-cal .azioni { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
|
||
.scheda-cal .azioni button { padding: 3px 10px; font-size: .82rem; }
|
||
.segno { display: inline-block; font-size: .72rem; padding: 0 6px; border-radius: 9px; background: var(--ok); color: #052e16; margin-left: 6px; }
|
||
.segno.diversa { background: var(--avviso); color: #422006; }
|
||
@media (max-width: 900px) {
|
||
.griglia { grid-template-columns: minmax(0, 1fr); }
|
||
.riquadri.doppio { grid-template-columns: minmax(0, 1fr); } /* #92: in colonna */
|
||
.cornice img { max-height: 60vh; }
|
||
}
|
||
@media (max-width: 500px) { /* #91: utilizzabile anche stretta (400 px) */
|
||
main { padding: 8px; }
|
||
.scheda { padding: 10px; }
|
||
.tabs button { flex: 1 1 0; padding: 8px 6px; }
|
||
.parametro .campo input[type=text] { width: 88px; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<main>
|
||
<h1>Acquisizione camera</h1>
|
||
|
||
<!-- #91: due tab; #92: l'anteprima sta nella tab Acquisizione e si sospende nella tab Calibrazione -->
|
||
<div class="tabs" role="tablist" aria-label="Sezioni della pagina">
|
||
<button id="tabAcq" type="button" role="tab" aria-selected="true" aria-controls="pannelloAcq">Acquisizione</button>
|
||
<button id="tabCal" type="button" role="tab" aria-selected="false" aria-controls="pannelloCal" tabindex="-1">Calibrazione</button>
|
||
</div>
|
||
|
||
<div class="colonna" id="pannelloAcq" role="tabpanel" aria-labelledby="tabAcq">
|
||
<!-- #92: Sorgente in cima, a larghezza piena: camere, cartella e immagini caricate da file -->
|
||
<section class="scheda" id="sezioneSorgente">
|
||
<h2>Sorgente</h2>
|
||
<div class="sorgente-riga">
|
||
<div class="campo-sorgente">
|
||
<label for="sorgente">Camera o immagine</label>
|
||
<select id="sorgente"></select>
|
||
</div>
|
||
<button id="aggiornaSorgenti" class="secondario" type="button" title="Cerca di nuovo le camere collegate">Aggiorna</button>
|
||
<button id="caricaImmagine" class="secondario" type="button">Carica immagine da file...</button>
|
||
<input id="fileImmagine" type="file" accept="image/*" hidden>
|
||
<button id="rimuoviImmagine" class="secondario" type="button" hidden>Rimuovi immagine</button>
|
||
</div>
|
||
<div class="stato" id="statoSorgente">-</div>
|
||
<div id="msgSorgente" class="messaggio"></div>
|
||
</section>
|
||
|
||
<!-- #87: sezione Anteprima; #92: barra, due riquadri affiancati (grezza | calibrata), indicatori dal frame grezzo -->
|
||
<section class="scheda" id="sezioneAnteprima">
|
||
<h2>Anteprima</h2>
|
||
<div class="riga">
|
||
<button id="avvia">Avvia anteprima</button>
|
||
<button id="ferma" class="secondario" disabled>Ferma anteprima</button>
|
||
<button id="scatta" class="secondario">Scatta</button>
|
||
<label style="margin:0 0 6px"><input id="mostraCal" type="checkbox"> Attiva calibrazione</label>
|
||
<label style="margin:0 0 6px"><input id="mirinoCasella" type="checkbox"> Mirino centrale</label>
|
||
</div>
|
||
<div class="riquadri" id="riquadri">
|
||
<div class="riquadro">
|
||
<div class="titolo">Immagine<span>grezza, senza correzione</span></div>
|
||
<div class="palco">
|
||
<div id="vuoto">Anteprima ferma. Premere «Avvia anteprima» o «Scatta».</div>
|
||
<div class="cornice" id="cornice" hidden>
|
||
<img id="immagine" alt="Anteprima grezza della sorgente">
|
||
<div id="mirino" class="mirino" hidden></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="riquadro" id="riquadroCal" hidden>
|
||
<div class="titolo">Calibrata<span id="nomeCalibrata"></span></div>
|
||
<div class="palco">
|
||
<div id="vuotoCal">Premere «Avvia anteprima» o «Scatta».</div>
|
||
<div class="cornice" id="corniceCal" hidden>
|
||
<img id="immagineCal" alt="Anteprima della sorgente raddrizzata dalla calibrazione">
|
||
<div id="mirinoCal" class="mirino" hidden></div>
|
||
</div>
|
||
</div>
|
||
<!-- #93: scelta della calibrazione da attivare sulla sorgente, con CONFERMA -->
|
||
<div class="riga scelta-cal">
|
||
<select id="sceltaCal" aria-label="Calibrazione da attivare" disabled></select>
|
||
<button id="confermaCal" type="button" disabled>CONFERMA</button>
|
||
</div>
|
||
<div id="msgSceltaCal" class="messaggio"></div>
|
||
</div>
|
||
</div>
|
||
<div class="indicatori">
|
||
<div class="indicatore">
|
||
<div class="nome">Nitidezza</div>
|
||
<div class="valore" id="nitidezza">-</div>
|
||
<div class="barra"><div id="barraNitidezza"></div></div>
|
||
<div class="piccolo">massimo visto: <span id="nitidezzaMax">-</span>
|
||
<button id="azzeraMax" class="secondario" style="padding:1px 7px;font-size:.75rem;margin-left:6px">Azzera</button></div>
|
||
</div>
|
||
<div class="indicatore">
|
||
<div class="nome">Saturati</div>
|
||
<div class="valore" id="saturati">-</div>
|
||
<div class="piccolo">pixel a 250 o più; da tenere sotto l'1 %</div>
|
||
</div>
|
||
<div class="indicatore">
|
||
<div class="nome">Livello medio</div>
|
||
<div class="valore" id="media">-</div>
|
||
<div class="piccolo">scala 0-255</div>
|
||
</div>
|
||
<div class="indicatore">
|
||
<div class="nome">Frame pieno</div>
|
||
<div class="valore" id="dimensioni">-</div>
|
||
<div class="piccolo" id="raddrizzato"> </div>
|
||
</div>
|
||
</div>
|
||
<div id="msgAnteprima" class="messaggio"></div>
|
||
</section>
|
||
|
||
<!-- #87: sezione Parametri camera -->
|
||
<section class="scheda" id="sezioneParametri">
|
||
<h2>Parametri camera</h2>
|
||
<div id="parametri"><div class="nota">-</div></div>
|
||
<div class="riga" id="salvaParametriRiga" hidden>
|
||
<button id="salvaParametri" class="secondario">Salva come predefiniti</button>
|
||
</div>
|
||
<div id="msgParametri" class="messaggio"></div>
|
||
</section>
|
||
</div>
|
||
|
||
<!-- #91: tab Calibrazione; #92: sempre sul frame grezzo della sorgente scelta in Acquisizione, niente file qui -->
|
||
<div class="griglia" id="pannelloCal" role="tabpanel" aria-labelledby="tabCal" hidden>
|
||
<section class="scheda" id="sezioneCalibrazione">
|
||
<h2>Calibrazione</h2>
|
||
<div class="stato" style="margin:0 0 10px">Sorgente: <strong id="calSorgente">-</strong> — immagine grezza dalla tab Acquisizione</div>
|
||
<div class="riga" style="margin-top:10px">
|
||
<div><label for="quadrato">Lato dello scacco (mm)</label><input id="quadrato" type="text" inputmode="decimal" value="2" size="8"></div>
|
||
<button id="acquisisci" title="Acquisisci e calibra">Calcola</button>
|
||
</div>
|
||
<p class="nota">
|
||
Servono una scacchiera che riempie tutto il campo della camera, piana, ferma e ben illuminata, e il lato di uno scacco.
|
||
La griglia si trova da sola; il risultato corregge anche la distorsione dell'ottica.
|
||
Il frame è sempre quello grezzo della sorgente; l'anteprima resta sospesa finché questa tab è aperta.</p>
|
||
<div id="messaggio" class="messaggio"></div>
|
||
|
||
<div id="risultato" hidden style="margin-top:10px">
|
||
<strong>Risultato del calcolo</strong>
|
||
<div id="origineCalcolo" class="nota" style="margin:2px 0 6px"></div>
|
||
<table id="tabella"></table>
|
||
<div id="avviso" class="avviso"></div>
|
||
<div class="riga" style="margin-top:10px">
|
||
<div><label for="nomeCal">Nome</label><input id="nomeCal" type="text" maxlength="60" size="22" autocomplete="off"></div>
|
||
<button id="salva">Salva</button>
|
||
</div>
|
||
<div id="msgSalva" class="messaggio"></div>
|
||
<img id="overlay" alt="Angoli trovati sulla scacchiera" style="max-width:100%;margin-top:8px;border:1px solid var(--bordo);border-radius:6px">
|
||
</div>
|
||
</section>
|
||
|
||
<section class="scheda" id="sezioneSalvate">
|
||
<h2>Calibrazioni salvate</h2>
|
||
<div id="stato" class="stato">-</div>
|
||
<div id="calibrazioni" class="elenco"></div> <!-- #91: una scheda per calibrazione, niente tabella larga -->
|
||
<div id="msgElenco" class="messaggio"></div>
|
||
</section>
|
||
</div>
|
||
</main>
|
||
|
||
<script>
|
||
"use strict";
|
||
var $ = function (id) { return document.getElementById(id); };
|
||
var ultimo = null; // ultimo calcolo di calibrazione, 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
|
||
// #87: cifre = decimali massimi; i parametri camera ne usano 1 (la camera rilegge 11999,993164 per 12000)
|
||
function fmtCasella(v, cifre) { return Number(v).toLocaleString("it-IT", { maximumFractionDigits: cifre === undefined ? 6 : cifre, 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);
|
||
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
|
||
|
||
// #92: scegli = nome da selezionare (immagine appena caricata); senza, l'ultima scelta ricordata
|
||
// #92: aggiorna = true forza la ricerca camere del servizio (camera appena collegata); di norma e' in cache
|
||
async function caricaSorgenti(scegli, aggiorna) {
|
||
var sel = $("sorgente");
|
||
var prima = sel.value;
|
||
sel.textContent = "";
|
||
try {
|
||
var r = await fetch("/camera/sources" + (aggiorna ? "?aggiorna=true" : ""));
|
||
var j = await r.json();
|
||
(j.sources || []).forEach(function (s) {
|
||
var o = document.createElement("option");
|
||
o.value = s.name;
|
||
o.textContent = s.label || s.name;
|
||
o.dataset.kind = s.kind || ""; // #92: «image» = immagine caricata da file
|
||
sel.appendChild(o);
|
||
});
|
||
} catch (e) { dire("msgSorgente", "Elenco delle sorgenti non disponibile.", "errore"); }
|
||
if (!sel.options.length) dire("msgSorgente", "Nessuna sorgente camera trovata.", "errore");
|
||
var voluta = scegli || (prima === "" ? memoria("tmf.acq.sorgente") : prima); // #87: ultima scelta ricordata
|
||
if (voluta) {
|
||
for (var i = 0; i < sel.options.length; i++) if (sel.options[i].value === voluta) sel.value = voluta;
|
||
}
|
||
cambioSorgente(!!scegli);
|
||
}
|
||
|
||
// #92: la sorgente selezionata e' un'immagine caricata (si puo' rimuovere)?
|
||
function sorgenteImmagine() {
|
||
var sel = $("sorgente");
|
||
return sel.selectedIndex >= 0 && sel.options[sel.selectedIndex].dataset.kind === "image";
|
||
}
|
||
|
||
// #92: aggiunge (o aggiorna) la voce dell'immagine {source, label, kind} nella select e la seleziona
|
||
function aggiungiSorgente(j) {
|
||
var sel = $("sorgente");
|
||
var o = null;
|
||
for (var i = 0; i < sel.options.length; i++) if (sel.options[i].value === j.source) o = sel.options[i];
|
||
if (!o) { o = document.createElement("option"); o.value = j.source; sel.appendChild(o); }
|
||
o.textContent = j.label || j.source;
|
||
o.dataset.kind = j.kind || "image";
|
||
sel.value = j.source;
|
||
cambioSorgente(true);
|
||
}
|
||
|
||
// #92: «Aggiorna» = ricerca camere nuova (sul servizio puo' durare ~10 s)
|
||
$("aggiornaSorgenti").onclick = async function () {
|
||
$("aggiornaSorgenti").disabled = true;
|
||
dire("msgSorgente", "Ricerca delle camere in corso...", "");
|
||
await caricaSorgenti(undefined, true);
|
||
dire("msgSorgente", "Elenco aggiornato.", "ok");
|
||
$("aggiornaSorgenti").disabled = false;
|
||
};
|
||
|
||
// #92: «Carica immagine da file...» -> POST /camera/immagini, poi la nuova immagine diventa la sorgente
|
||
$("caricaImmagine").onclick = function () { $("fileImmagine").click(); };
|
||
$("fileImmagine").onchange = async function () {
|
||
var file = $("fileImmagine").files[0];
|
||
$("fileImmagine").value = ""; // lo stesso file si puo' ricaricare
|
||
if (!file) return;
|
||
var f = new FormData();
|
||
f.append("image", file);
|
||
$("caricaImmagine").disabled = true;
|
||
dire("msgSorgente", "Caricamento di «" + file.name + "»...", "");
|
||
try {
|
||
var r = await fetch("/camera/immagini", { method: "POST", body: f });
|
||
if (!r.ok) { dire("msgSorgente", await dettaglio(r), "errore"); return; }
|
||
var j = await r.json();
|
||
aggiungiSorgente(j); // #92: la ricerca camere puo' durare ~10 s: la voce nuova si aggiunge senza rileggere l'elenco
|
||
dire("msgSorgente", "Immagine «" + j.source + "» caricata: premere «Scatta» per vederla.", "ok");
|
||
} catch (e) {
|
||
dire("msgSorgente", "Servizio non raggiungibile.", "errore");
|
||
} finally {
|
||
$("caricaImmagine").disabled = false;
|
||
}
|
||
};
|
||
|
||
// #92: «Rimuovi immagine» con doppio clic entro 4 s (niente finestre del browser)
|
||
var R = { timer: null };
|
||
function annullaRimuovi() {
|
||
clearTimeout(R.timer);
|
||
R.timer = null;
|
||
$("rimuoviImmagine").textContent = "Rimuovi immagine";
|
||
}
|
||
$("rimuoviImmagine").onclick = async function () {
|
||
var nome = $("sorgente").value;
|
||
if (!nome || !sorgenteImmagine()) return;
|
||
if (R.timer === null) {
|
||
$("rimuoviImmagine").textContent = "Confermi? Clic ancora";
|
||
R.timer = setTimeout(annullaRimuovi, 4000);
|
||
return;
|
||
}
|
||
annullaRimuovi();
|
||
fermaAnteprima(); // la sorgente sta per sparire: niente richieste in volo su di lei
|
||
while (A.inVolo) await pausa(50);
|
||
try {
|
||
var r = await fetch("/camera/immagini?nome=" + encodeURIComponent(nome), { method: "DELETE" });
|
||
if (!r.ok) { dire("msgSorgente", await dettaglio(r), "errore"); return; }
|
||
var sel = $("sorgente"); // #92: voce tolta in locale, senza rileggere l'elenco dal servizio
|
||
sel.removeChild(sel.options[sel.selectedIndex]);
|
||
if (sel.options.length) sel.selectedIndex = 0;
|
||
cambioSorgente(true);
|
||
dire("msgSorgente", "Immagine «" + nome + "» rimossa.", "ok");
|
||
} catch (e) {
|
||
dire("msgSorgente", "Servizio non raggiungibile.", "errore");
|
||
}
|
||
};
|
||
|
||
// #87: cambiando sorgente si azzerano gli indicatori e si rileggono parametri e calibrazione
|
||
function cambioSorgente(salva) {
|
||
if (salva) memoria("tmf.acq.sorgente", $("sorgente").value);
|
||
var era = A.attiva;
|
||
fermaAnteprima();
|
||
azzeraIndicatori();
|
||
svuotaRiquadri(); // #92: il frame vecchio non e' della nuova sorgente
|
||
A.calAttiva = null; A.bloccoCal = false; // #92: stato calibrazione della nuova sorgente ancora da leggere
|
||
annullaRimuovi();
|
||
$("rimuoviImmagine").hidden = !sorgenteImmagine();
|
||
dire("msgAnteprima", "");
|
||
dire("msgElenco", ""); // #91
|
||
aggiornaStato();
|
||
caricaParametri();
|
||
if (era) avviaAnteprima();
|
||
}
|
||
|
||
// ---- 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, riprendi = ciclo sospeso dalla tab Calibrazione, lato = ultimo lato_max
|
||
var A = { attiva: false, gen: 0, inVolo: false, url: null, urlCal: null, seqCal: 0, riprendi: false, lato: 1280, calAttiva: null, bloccoCal: false, nitMax: 0, ris: null };
|
||
|
||
function azzeraIndicatori() {
|
||
A.nitMax = 0;
|
||
["nitidezza", "saturati", "media", "dimensioni", "nitidezzaMax"].forEach(function (i) { $(i).textContent = "-"; });
|
||
$("saturati").className = "valore";
|
||
$("barraNitidezza").style.width = "0";
|
||
$("raddrizzato").textContent = " ";
|
||
A.ris = null; // #91: risoluzione del frame non piu' nota
|
||
segnaRisoluzioni();
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
// #92: riquadro «Calibrata»: l'immagine raddrizzata oppure il motivo per cui non c'e' (404/409 del servizio)
|
||
function mostraCalibrata(blob, nome) {
|
||
var vecchio = A.urlCal;
|
||
A.urlCal = URL.createObjectURL(blob);
|
||
$("immagineCal").src = A.urlCal;
|
||
if (vecchio) URL.revokeObjectURL(vecchio);
|
||
$("corniceCal").hidden = false;
|
||
$("vuotoCal").hidden = true;
|
||
$("nomeCalibrata").textContent = nome ? "«" + nome + "»" : "";
|
||
}
|
||
function messaggioCalibrata(testo) {
|
||
$("corniceCal").hidden = true;
|
||
$("vuotoCal").hidden = false;
|
||
$("vuotoCal").textContent = testo;
|
||
$("nomeCalibrata").textContent = "";
|
||
}
|
||
// #92: cambio sorgente: via i due frame, nessuna immagine di un'altra sorgente
|
||
function svuotaRiquadri() {
|
||
A.seqCal++; // una risposta calibrata ancora in volo non vale piu'
|
||
$("cornice").hidden = true;
|
||
$("vuoto").hidden = false;
|
||
messaggioCalibrata("Premere «Avvia anteprima» o «Scatta».");
|
||
}
|
||
// #92: raddrizza l'ULTIMO frame grezzo tenuto dal servizio (nessuna lettura nuova); chiamata dopo ogni anteprima, dopo Carica/Scollega e alla casella
|
||
async function aggiornaCalibrata(lato) {
|
||
var sorgente = $("sorgente").value;
|
||
if (!$("mostraCal").checked || !sorgente) return;
|
||
if (A.calAttiva === false) { // #92: lo stato dice che non c'e' calibrazione: niente richiesta al servizio
|
||
A.seqCal++;
|
||
messaggioCalibrata("nessuna calibrazione attiva su questa sorgente");
|
||
return;
|
||
}
|
||
if (A.bloccoCal) return; // #92: dopo un 409 (risoluzione diversa, planare) si richiede solo dopo Carica/Scollega o cambio sorgente
|
||
var n = ++A.seqCal;
|
||
try {
|
||
var r = await fetch("/camera/anteprima/calibrata", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ source: sorgente, lato_max: lato || A.lato })
|
||
});
|
||
if (n !== A.seqCal) return; // risposta superata da una piu' recente o da un cambio sorgente
|
||
if (!r.ok) { // l'anteprima grezza continua
|
||
if (r.status === 409) A.bloccoCal = true; // #92: inutile richiedere a ogni frame
|
||
messaggioCalibrata(await dettaglio(r));
|
||
return;
|
||
}
|
||
var nome = "";
|
||
try { nome = decodeURIComponent(r.headers.get("X-Calibrazione") || ""); } catch (e) { /* intestazione non decodificabile: nessun nome */ }
|
||
var blob = await r.blob();
|
||
if (n !== A.seqCal) return;
|
||
mostraCalibrata(blob, nome);
|
||
} catch (e) {
|
||
if (n === A.seqCal) messaggioCalibrata("Servizio non raggiungibile.");
|
||
}
|
||
}
|
||
|
||
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);
|
||
// #91: risoluzione nota del frame, per segnare le calibrazioni che non combaciano
|
||
var nw = parseInt(w, 10), na = parseInt(a, 10);
|
||
if (!A.ris || A.ris.w !== nw || A.ris.h !== na) { A.ris = { w: nw, h: na }; segnaRisoluzioni(); }
|
||
}
|
||
$("raddrizzato").textContent = "frame grezzo"; // #92: l'indicatore viene sempre dal frame grezzo
|
||
}
|
||
|
||
// una richiesta di anteprima; ritorna "ok" | "errore" | "fatale" (errore che non passa riprovando)
|
||
async function unaAnteprima(lato) {
|
||
var sorgente = $("sorgente").value;
|
||
if (!sorgente) { dire("msgAnteprima", "Scegliere una sorgente.", "errore"); return "fatale"; }
|
||
var g = A.gen;
|
||
A.lato = lato;
|
||
A.inVolo = true;
|
||
try {
|
||
var r = await fetch("/camera/anteprima", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ source: sorgente, grezzo: true, lato_max: lato, timeout_ms: 5000 }) // #92: il riquadro «Immagine» e' sempre grezzo
|
||
});
|
||
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", "");
|
||
await aggiornaCalibrata(lato); // #92: stesso frame, raddrizzato; resta in volo finche' non risponde
|
||
return "ok";
|
||
} catch (e) {
|
||
if (g === A.gen) dire("msgAnteprima", "Servizio non raggiungibile.", "errore");
|
||
return "errore";
|
||
} finally {
|
||
A.inVolo = false;
|
||
}
|
||
}
|
||
|
||
// ciclo sequenziale: la richiesta dopo parte solo a risposta arrivata, almeno 100 ms dopo la precedente (#90: ~10 fps)
|
||
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(100 - (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"; };
|
||
// #92: il mirino vale per entrambi i riquadri; #93: la casella «Attiva calibrazione» apre il riquadro e raddrizza il frame gia' tenuto
|
||
$("mirinoCasella").onchange = function () { $("mirino").hidden = $("mirinoCal").hidden = !$("mirinoCasella").checked; };
|
||
function mostraRiquadroCal() {
|
||
var si = $("mostraCal").checked;
|
||
$("riquadroCal").hidden = !si;
|
||
$("riquadri").className = "riquadri" + (si ? " doppio" : "");
|
||
if (si) aggiornaCalibrata();
|
||
}
|
||
$("mostraCal").onchange = mostraRiquadroCal;
|
||
|
||
// ---- Parametri camera (#87)
|
||
|
||
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; }
|
||
// #91: subito via il testo della sorgente precedente; la prima apertura della camera puo' durare ~20 s
|
||
$("parametri").textContent = "";
|
||
var attesa = document.createElement("div");
|
||
attesa.className = "nota";
|
||
attesa.textContent = "Lettura dei parametri...";
|
||
$("parametri").appendChild(attesa);
|
||
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, 1);
|
||
modifica(nome, v);
|
||
};
|
||
campo.appendChild(cur);
|
||
}
|
||
casella.type = "text";
|
||
casella.inputMode = "decimal";
|
||
casella.value = fmtCasella(d.valore, 1);
|
||
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, 1); return; }
|
||
v = adatta(d, v);
|
||
casella.value = fmtCasella(v, 1);
|
||
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, 1);
|
||
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;
|
||
}
|
||
};
|
||
|
||
// ---- Tab (#91): «Acquisizione» | «Calibrazione»; l'anteprima sta fuori dai pannelli e resta sempre visibile
|
||
|
||
function scegliTab(nome, salva) {
|
||
var cal = nome === "cal";
|
||
$("tabAcq").setAttribute("aria-selected", cal ? "false" : "true");
|
||
$("tabCal").setAttribute("aria-selected", cal ? "true" : "false");
|
||
$("tabAcq").tabIndex = cal ? -1 : 0;
|
||
$("tabCal").tabIndex = cal ? 0 : -1;
|
||
$("pannelloAcq").hidden = cal;
|
||
$("pannelloCal").hidden = !cal;
|
||
// #92: nella tab Calibrazione il ciclo anteprima si sospende; tornando in Acquisizione riprende se era attivo
|
||
if (cal && A.attiva) { A.riprendi = true; fermaAnteprima(); }
|
||
if (!cal && A.riprendi) { A.riprendi = false; avviaAnteprima(); }
|
||
if (!cal && salva) aggiornaCalibrata(); // il riquadro calibrata riflette la calibrazione attiva di adesso
|
||
if (salva) { memoria("tmf.acq.tab", nome); if (cal) caricaCalibrazioni(); } // l'elenco si rilegge entrando
|
||
}
|
||
$("tabAcq").onclick = function () { scegliTab("acq", true); };
|
||
$("tabCal").onclick = function () { scegliTab("cal", true); };
|
||
[$("tabAcq"), $("tabCal")].forEach(function (b) { // frecce sinistra/destra fra le tab
|
||
b.onkeydown = function (e) {
|
||
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
|
||
var cal = b.id === "tabAcq";
|
||
scegliTab(cal ? "cal" : "acq", true);
|
||
$(cal ? "tabCal" : "tabAcq").focus();
|
||
};
|
||
});
|
||
|
||
// ---- Calibrazione (logica di #83 e #84; #87: lato precompilato, data italiana, conferma in pagina)
|
||
|
||
function righe(d) {
|
||
return [
|
||
["Scala", fmt(d.px_per_mm, 4) + " px/mm (" + fmt(d.mm_per_px, 5) + " mm/px)"],
|
||
["Metodo", d.metodo !== "densa" ? "planare (senza correzione della distorsione)" : "densa (immagine raddrizzata)"],
|
||
["Griglia trovata", d.cols + " x " + d.rows + " angoli (" + fmt(d.n_corners, 0) + ")"]
|
||
].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) + " %"]
|
||
] : [
|
||
["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) + " %"]
|
||
]).concat([
|
||
["Lato dello scacco", fmt(d.square_mm) + " mm"],
|
||
["Immagine", fmt(d.image_width, 0) + " x " + fmt(d.image_height, 0) + " px"]
|
||
]);
|
||
}
|
||
|
||
function tabella(el, d) {
|
||
el.textContent = "";
|
||
righe(d).forEach(function (r) {
|
||
var tr = document.createElement("tr");
|
||
r.forEach(function (c) {
|
||
var td = document.createElement("td");
|
||
td.textContent = c;
|
||
tr.appendChild(td);
|
||
});
|
||
el.appendChild(tr);
|
||
});
|
||
}
|
||
|
||
// 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.");
|
||
} 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.");
|
||
}
|
||
return a.join(" ");
|
||
}
|
||
|
||
// ---- Calibrazioni con nome (#91): archivio unico, una attiva per sorgente
|
||
|
||
var C = { elenco: [], attiva: null, celle: [], gen: 0, confermaNome: null, confermaBtn: null, confermaTimer: null };
|
||
var S = { nome: null, timer: null }; // secondo clic su «Sovrascrivi» entro 4 s
|
||
var JSON_H = { "Content-Type": "application/json" };
|
||
|
||
function descriviOrigine(o) { return String(o || "-").replace(":", " "); }
|
||
|
||
// stato della calibrazione attiva sulla sorgente selezionata (come prima di #91, piu' il nome)
|
||
async function aggiornaStato() {
|
||
var sorgente = $("sorgente").value;
|
||
var stato = $("stato");
|
||
annullaConferma();
|
||
$("calSorgente").textContent = sorgente ? $("sorgente").options[$("sorgente").selectedIndex].textContent : "-"; // #91
|
||
if (!sorgente) { stato.textContent = "-"; $("statoSorgente").textContent = "-"; caricaCalibrazioni(); 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";
|
||
A.calAttiva = false; // #92
|
||
} else if (!r.ok) {
|
||
stato.textContent = await dettaglio(r);
|
||
} else {
|
||
var d = await r.json();
|
||
A.calAttiva = true; // #92
|
||
stato.textContent = "";
|
||
var titolo = document.createElement("div");
|
||
titolo.textContent = "Calibrazione attiva: " + (d.nome ? "«" + d.nome + "»" : "(senza nome)");
|
||
titolo.style.fontWeight = "600";
|
||
var t = document.createElement("table");
|
||
tabella(t, d);
|
||
var quando = document.createElement("div");
|
||
quando.textContent = "Calibrata il " + dataItaliana(d.calibrated_at);
|
||
stato.appendChild(titolo);
|
||
stato.appendChild(t);
|
||
stato.appendChild(quando);
|
||
$("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
|
||
}
|
||
} catch (e) {
|
||
stato.textContent = "Servizio non raggiungibile.";
|
||
}
|
||
caricaCalibrazioni();
|
||
}
|
||
|
||
// #91: elenco unico delle calibrazioni salvate; "attiva" e' quella della sorgente selezionata
|
||
async function caricaCalibrazioni() {
|
||
var sorgente = $("sorgente").value;
|
||
var g = ++C.gen;
|
||
try {
|
||
var r = await fetch("/camera/calibrazioni" + (sorgente ? "?source=" + encodeURIComponent(sorgente) : ""));
|
||
if (g !== C.gen) return;
|
||
if (!r.ok) { dire("msgElenco", await dettaglio(r), "errore"); return; }
|
||
var j = await r.json();
|
||
if (g !== C.gen) return;
|
||
C.elenco = j.calibrazioni || [];
|
||
C.attiva = j.attiva || null;
|
||
disegnaElenco();
|
||
} catch (e) {
|
||
if (g === C.gen) dire("msgElenco", "Servizio non raggiungibile.", "errore");
|
||
}
|
||
}
|
||
|
||
function bottone(testo, classe, fn) {
|
||
var b = document.createElement("button");
|
||
b.textContent = testo;
|
||
b.className = classe || "";
|
||
b.onclick = fn;
|
||
return b;
|
||
}
|
||
|
||
function disegnaElenco() {
|
||
var t = $("calibrazioni");
|
||
annullaConferma();
|
||
t.textContent = "";
|
||
C.celle = [];
|
||
disegnaSceltaCal(); // #93: la combo sotto la calibrata segue l'elenco, anche se vuoto
|
||
if (!C.elenco.length) {
|
||
var vuota = document.createElement("div");
|
||
vuota.className = "nota";
|
||
vuota.textContent = "Nessuna calibrazione salvata.";
|
||
t.appendChild(vuota);
|
||
return;
|
||
}
|
||
C.elenco.forEach(function (c) {
|
||
var sc = document.createElement("div"); // #91: scheda della calibrazione
|
||
sc.className = "scheda-cal";
|
||
var attiva = C.attiva !== null && c.nome === C.attiva;
|
||
var riga1 = document.createElement("div");
|
||
riga1.className = "nome-cal";
|
||
riga1.appendChild(document.createTextNode(c.nome));
|
||
if (attiva) {
|
||
var s1 = document.createElement("span");
|
||
s1.className = "segno";
|
||
s1.textContent = "attiva";
|
||
riga1.appendChild(s1);
|
||
}
|
||
var s2 = document.createElement("span"); // «risoluzione diversa» dal frame della sorgente (si accende in segnaRisoluzioni)
|
||
s2.className = "segno diversa";
|
||
s2.textContent = "risoluzione diversa";
|
||
s2.hidden = true;
|
||
riga1.appendChild(s2);
|
||
C.celle.push({ w: Number(c.image_width), h: Number(c.image_height), span: s2 });
|
||
var riga2 = document.createElement("div");
|
||
riga2.className = "dettagli";
|
||
riga2.textContent = [
|
||
c.image_width && c.image_height ? fmt(c.image_width, 0) + "×" + fmt(c.image_height, 0) : "-",
|
||
fmt(c.px_per_mm, 3) + " px/mm",
|
||
c.metodo === "densa" ? "densa" : "planare",
|
||
c.calibrated_at ? dataItaliana(c.calibrated_at) : "-",
|
||
descriviOrigine(c.origine)
|
||
].join(" · ");
|
||
var az = document.createElement("div");
|
||
az.className = "azioni";
|
||
var bc = bottone("Carica", "", function () { caricaNome(c.nome); });
|
||
bc.disabled = !$("sorgente").value;
|
||
az.appendChild(bc);
|
||
if (attiva) az.appendChild(bottone("Scollega", "secondario", function () { scollega("msgElenco"); })); // #93: senza l'evento come parametro
|
||
az.appendChild(bottone("Elimina", "secondario", function (e) { eliminaNome(c.nome, e.currentTarget); }));
|
||
sc.appendChild(riga1);
|
||
sc.appendChild(riga2);
|
||
sc.appendChild(az);
|
||
t.appendChild(sc);
|
||
});
|
||
segnaRisoluzioni();
|
||
}
|
||
|
||
// #91: «risoluzione diversa» se il frame noto della sorgente non combacia con quello della calibrazione
|
||
function segnaRisoluzioni() {
|
||
(C.celle || []).forEach(function (c) {
|
||
c.span.hidden = !(A.ris && c.w > 0 && c.h > 0 && (A.ris.w !== c.w || A.ris.h !== c.h));
|
||
});
|
||
// #93: stesso avviso nelle voci della combo; si cambia solo il testo, cosi' scelta e menu aperto restano
|
||
var sel = $("sceltaCal");
|
||
for (var i = 0; i < sel.options.length; i++) if (sel.options[i].value) sel.options[i].textContent = testoVoceCal(sel.options[i].dataset);
|
||
}
|
||
|
||
// #93: testo di una voce della combo: «nome · W×H · px/mm», con «risoluzione diversa» se il frame noto non combacia
|
||
function testoVoceCal(d) {
|
||
var w = Number(d.w), h = Number(d.h);
|
||
var t = d.nome + " · " + (w && h ? fmt(w, 0) + "×" + fmt(h, 0) : "-") + " · " + fmt(Number(d.px), 3) + " px/mm";
|
||
if (A.ris && w > 0 && h > 0 && (A.ris.w !== w || A.ris.h !== h)) t += " — risoluzione diversa";
|
||
return t;
|
||
}
|
||
// #93: ridisegna la combo (ogni ricarica dell'elenco); preselezionata la calibrazione attiva, senza sorgente tutto spento
|
||
function disegnaSceltaCal() {
|
||
var sel = $("sceltaCal");
|
||
sel.textContent = "";
|
||
var nessuna = document.createElement("option");
|
||
nessuna.value = "";
|
||
nessuna.textContent = "— nessuna (non calibrata) —";
|
||
sel.appendChild(nessuna);
|
||
(C.elenco || []).forEach(function (c) {
|
||
var o = document.createElement("option");
|
||
o.value = c.nome;
|
||
o.dataset.nome = c.nome; o.dataset.w = c.image_width || 0; o.dataset.h = c.image_height || 0; o.dataset.px = c.px_per_mm || 0;
|
||
o.textContent = testoVoceCal(o.dataset);
|
||
sel.appendChild(o);
|
||
});
|
||
sel.value = C.attiva !== null && C.attiva !== undefined ? C.attiva : "";
|
||
if (sel.selectedIndex < 0) sel.selectedIndex = 0;
|
||
sel.disabled = $("confermaCal").disabled = !$("sorgente").value;
|
||
}
|
||
|
||
// «Carica»: la calibrazione diventa quella attiva della sorgente. Un errore (es. risoluzione diversa) non ferma l'anteprima.
|
||
async function caricaNome(nome, idMsg) {
|
||
idMsg = idMsg || "msgElenco"; // #93: il messaggio puo' stare sotto la calibrata (msgSceltaCal)
|
||
var sorgente = $("sorgente").value;
|
||
if (!sorgente) { dire(idMsg, "Scegliere una sorgente nella tab Acquisizione.", "errore"); return; }
|
||
dire(idMsg, "Caricamento di «" + nome + "»...", "");
|
||
try {
|
||
var r = await fetch("/camera/calibrazione-attiva", { method: "PUT", headers: JSON_H, body: JSON.stringify({ source: sorgente, nome: nome }) });
|
||
if (sorgente !== $("sorgente").value) return;
|
||
if (!r.ok) { dire(idMsg, await dettaglio(r), "errore"); disegnaSceltaCal(); return; } // #93: la combo torna sull'attiva
|
||
dire(idMsg, "Calibrazione «" + nome + "» caricata sulla sorgente.", "ok");
|
||
A.bloccoCal = false; // #92
|
||
await aggiornaStato(); // prima lo stato nuovo (rilegge anche l'elenco, #93: schede e combo allineate), poi il riquadro calibrata
|
||
aggiornaCalibrata();
|
||
} catch (e) {
|
||
dire(idMsg, "Servizio non raggiungibile.", "errore");
|
||
disegnaSceltaCal(); // #93
|
||
}
|
||
}
|
||
|
||
// «Scollega»: la sorgente torna senza calibrazione, la libreria resta
|
||
async function scollega(idMsg) {
|
||
idMsg = idMsg || "msgElenco"; // #93
|
||
var sorgente = $("sorgente").value;
|
||
if (!sorgente) return;
|
||
try {
|
||
var r = await fetch("/camera/calibration?source=" + encodeURIComponent(sorgente), { method: "DELETE" });
|
||
dire(idMsg, r.ok ? "Calibrazione scollegata dalla sorgente (resta fra le salvate)." : await dettaglio(r), r.ok ? "ok" : "errore");
|
||
} catch (e) {
|
||
dire(idMsg, "Servizio non raggiungibile.", "errore");
|
||
}
|
||
A.bloccoCal = false; // #92
|
||
await aggiornaStato(); // #93: rilegge anche l'elenco, quindi la combo torna sull'attiva anche dopo un errore
|
||
aggiornaCalibrata(); // #92: senza calibrazione il riquadro dice che non c'e'
|
||
}
|
||
|
||
// #93: CONFERMA sotto la calibrata: voce scelta -> PUT calibrazione-attiva, «nessuna» -> DELETE calibration
|
||
$("confermaCal").onclick = function () {
|
||
var nome = $("sceltaCal").value;
|
||
if (nome) caricaNome(nome, "msgSceltaCal"); else scollega("msgSceltaCal");
|
||
};
|
||
|
||
// «Elimina» di una riga: conferma in pagina con un secondo clic entro 4 s (niente finestre del browser)
|
||
function annullaConferma() {
|
||
clearTimeout(C.confermaTimer);
|
||
C.confermaTimer = null;
|
||
if (C.confermaBtn) C.confermaBtn.textContent = "Elimina";
|
||
C.confermaBtn = null;
|
||
C.confermaNome = null;
|
||
}
|
||
async function eliminaNome(nome, btn) {
|
||
if (C.confermaNome !== nome) {
|
||
annullaConferma();
|
||
C.confermaNome = nome;
|
||
C.confermaBtn = btn;
|
||
btn.textContent = "Confermi? Clic ancora";
|
||
C.confermaTimer = setTimeout(annullaConferma, 4000);
|
||
return;
|
||
}
|
||
annullaConferma();
|
||
try {
|
||
var r = await fetch("/camera/calibrazioni?nome=" + encodeURIComponent(nome), { method: "DELETE" });
|
||
dire("msgElenco", r.ok ? "Calibrazione «" + nome + "» eliminata." : await dettaglio(r), r.ok ? "ok" : "errore");
|
||
} catch (e) {
|
||
dire("msgElenco", "Servizio non raggiungibile.", "errore");
|
||
}
|
||
aggiornaStato();
|
||
}
|
||
|
||
// #91: il calcolo non salva mai; il servizio tiene il risultato e ne da' il calcolo_id per «Salva»
|
||
async function calcola() {
|
||
var sorgente = $("sorgente").value; // #92: sempre la sorgente della tab Acquisizione, frame grezzo
|
||
if (!sorgente) { messaggio("Scegliere una sorgente nella tab Acquisizione.", "errore"); return; }
|
||
var lato = leggiNumero($("quadrato").value);
|
||
if (!(lato > 0)) { messaggio("Il lato dello scacco deve essere un numero maggiore di zero.", "errore"); return; }
|
||
var f = new FormData();
|
||
f.append("square_mm", String(lato)); // al servizio sempre con il punto
|
||
f.append("source", sorgente);
|
||
fermaAnteprima(); // #92: in questa tab il ciclo e' gia' sospeso; qui si aspetta anche l'eventuale richiesta ancora in volo
|
||
messaggio("Calcolo in corso...", "");
|
||
["acquisisci", "salva"].forEach(function (i) { $(i).disabled = true; });
|
||
try {
|
||
while (A.inVolo) await pausa(50);
|
||
var r = await fetch("/camera/calibrate", { method: "POST", body: f });
|
||
if (!r.ok) { messaggio(await dettaglio(r), "errore"); return; }
|
||
var d = await r.json();
|
||
ultimo = { dati: d };
|
||
tabella($("tabella"), d);
|
||
$("overlay").src = "data:image/png;base64," + d.overlay_png;
|
||
$("avviso").textContent = avvisi(d);
|
||
$("origineCalcolo").textContent = "Origine: " + descriviOrigine(d.origine);
|
||
$("risultato").hidden = false;
|
||
annullaSovrascrivi();
|
||
dire("msgSalva", "");
|
||
messaggio("Calcolo riuscito: dare un nome e salvare.", "ok");
|
||
} catch (e) {
|
||
messaggio("Servizio non raggiungibile.", "errore");
|
||
} finally {
|
||
$("acquisisci").disabled = false;
|
||
$("salva").disabled = false;
|
||
}
|
||
}
|
||
|
||
// «Salva» con nome; se il nome c'e' gia' il pulsante chiede un secondo clic per sovrascrivere
|
||
function annullaSovrascrivi() {
|
||
clearTimeout(S.timer);
|
||
S.timer = null;
|
||
S.nome = null;
|
||
$("salva").textContent = "Salva";
|
||
}
|
||
async function salvaNome() {
|
||
if (!ultimo || !ultimo.dati.calcolo_id) { dire("msgSalva", "Nessun calcolo da salvare: calcolare prima.", "errore"); return; }
|
||
var nome = $("nomeCal").value.trim();
|
||
if (!nome) { dire("msgSalva", "Scrivere un nome per la calibrazione.", "errore"); return; }
|
||
if (nome.length > 60) { dire("msgSalva", "Il nome può avere al massimo 60 caratteri.", "errore"); return; }
|
||
var sovrascrivi = S.timer !== null && S.nome === nome.toLowerCase(); // secondo clic sullo stesso nome
|
||
annullaSovrascrivi();
|
||
$("salva").disabled = true;
|
||
try {
|
||
var r = await fetch("/camera/calibrazioni", {
|
||
method: "POST", headers: JSON_H,
|
||
body: JSON.stringify({ calcolo_id: ultimo.dati.calcolo_id, nome: nome, sovrascrivi: sovrascrivi })
|
||
});
|
||
if (r.status === 409) {
|
||
S.nome = nome.toLowerCase();
|
||
$("salva").textContent = "Sovrascrivi «" + nome + "»? Clic ancora";
|
||
S.timer = setTimeout(annullaSovrascrivi, 4000);
|
||
dire("msgSalva", await dettaglio(r), "errore");
|
||
return;
|
||
}
|
||
if (!r.ok) { dire("msgSalva", await dettaglio(r), "errore"); return; }
|
||
dire("msgSalva", "Calibrazione «" + nome + "» salvata. Per usarla su una sorgente: «Carica» nell'elenco.", "ok");
|
||
caricaCalibrazioni();
|
||
} catch (e) {
|
||
dire("msgSalva", "Servizio non raggiungibile.", "errore");
|
||
} finally {
|
||
$("salva").disabled = false;
|
||
}
|
||
}
|
||
|
||
$("acquisisci").onclick = calcola;
|
||
$("salva").onclick = salvaNome;
|
||
$("nomeCal").onkeydown = function (e) { if (e.key === "Enter") salvaNome(); };
|
||
$("sorgente").onchange = function () { dire("msgSorgente", ""); cambioSorgente(true); }; // #92
|
||
scegliTab(memoria("tmf.acq.tab") === "cal" ? "cal" : "acq", false); // #91: tab ricordata
|
||
caricaSorgenti();
|
||
</script>
|
||
</body>
|
||
</html>
|
||
"""
|