Files
TieMeasureFlow/scripts/calibrazione_pagina.py
T
Adriano d6cee498ad Acquisizione: pagina a due tab, calibrazioni salvate con nome e carica per sorgente (Refs #91)
Tab Acquisizione | Calibrazione con anteprima sempre visibile; il calcolo non salva piu',
Salva usa calcolo_id e un nome (conferma di sovrascrittura al secondo clic);
elenco unico con Carica / Scollega / Elimina e segno di risoluzione diversa.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 16:08:02 +02:00

1022 lines
44 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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.
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); }
.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;
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); }
/* #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 */
.scorri { overflow-x: auto; margin-top: 8px; }
.elenco { width: 100%; }
.elenco th { text-align: left; font-size: .78rem; font-weight: 600; color: var(--secondario); padding: 2px 12px 4px 0; white-space: nowrap; }
.elenco td { padding: 5px 12px 5px 0; border-top: 1px solid var(--bordo); vertical-align: top; }
.elenco td:first-child { color: var(--testo); }
.elenco .azioni { white-space: nowrap; }
.elenco button { padding: 3px 9px; font-size: .82rem; margin-right: 4px; }
.segno { display: inline-block; font-size: .72rem; padding: 0 6px; border-radius: 9px; background: var(--ok); color: #052e16; margin-left: 6px; }
.segno.diversa { background: var(--avviso); color: #422006; }
.origine label { display: inline-block; margin: 0 14px 4px 0; color: var(--testo); font-size: .92rem; }
input[type=radio] { accent-color: var(--accento); }
@media (max-width: 900px) {
.griglia { grid-template-columns: minmax(0, 1fr); }
.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; la colonna dell'anteprima resta visibile in entrambe -->
<div class="tabs" role="tablist" aria-label="Sezioni della pagina">
<button id="tabAcq" type="button" role="tab" aria-selected="true" aria-controls="pannelloAcq">Acquisizione</button>
<button id="tabCal" type="button" role="tab" aria-selected="false" aria-controls="pannelloCal" tabindex="-1">Calibrazione</button>
</div>
<div class="griglia">
<div class="colonna">
<!-- #87: sezione Anteprima -->
<section class="scheda" id="sezioneAnteprima">
<h2>Anteprima</h2>
<div class="riga">
<button id="avvia">Avvia anteprima</button>
<button id="ferma" class="secondario" disabled>Ferma anteprima</button>
<button id="scatta" class="secondario">Scatta</button>
<label style="margin:0 0 6px"><input id="grezzo" type="checkbox"> Immagine grezza (senza correzione)</label>
<label style="margin:0 0 6px"><input id="mirinoCasella" type="checkbox"> Mirino centrale</label>
</div>
<div class="palco">
<div id="vuoto">Anteprima ferma. Premere «Avvia anteprima» o «Scatta».</div>
<div class="cornice" id="cornice" hidden>
<img id="immagine" alt="Anteprima della camera">
<div id="mirino" hidden></div>
</div>
</div>
<div class="indicatori">
<div class="indicatore">
<div class="nome">Nitidezza</div>
<div class="valore" id="nitidezza">-</div>
<div class="barra"><div id="barraNitidezza"></div></div>
<div class="piccolo">massimo visto: <span id="nitidezzaMax">-</span>
<button id="azzeraMax" class="secondario" style="padding:1px 7px;font-size:.75rem;margin-left:6px">Azzera</button></div>
</div>
<div class="indicatore">
<div class="nome">Saturati</div>
<div class="valore" id="saturati">-</div>
<div class="piccolo">pixel a 250 o più; da tenere sotto l'1 %</div>
</div>
<div class="indicatore">
<div class="nome">Livello medio</div>
<div class="valore" id="media">-</div>
<div class="piccolo">scala 0-255</div>
</div>
<div class="indicatore">
<div class="nome">Frame pieno</div>
<div class="valore" id="dimensioni">-</div>
<div class="piccolo" id="raddrizzato">&nbsp;</div>
</div>
</div>
<div id="msgAnteprima" class="messaggio"></div>
</section>
</div>
<div class="colonna" id="pannelloAcq" role="tabpanel" aria-labelledby="tabAcq">
<!-- #87: sezione Sorgente -->
<section class="scheda" id="sezioneSorgente">
<h2>Sorgente</h2>
<label for="sorgente">Camera o cartella</label>
<select id="sorgente"></select>
<div class="stato" id="statoSorgente">-</div>
<div id="msgSorgente" class="messaggio"></div>
</section>
<!-- #87: sezione Parametri camera -->
<section class="scheda" id="sezioneParametri">
<h2>Parametri camera</h2>
<div id="parametri"><div class="nota">-</div></div>
<div class="riga" id="salvaParametriRiga" hidden>
<button id="salvaParametri" class="secondario">Salva come predefiniti</button>
</div>
<div id="msgParametri" class="messaggio"></div>
</section>
</div>
<!-- #91: tab Calibrazione (colonna destra al posto di Sorgente e Parametri) -->
<div class="colonna" id="pannelloCal" role="tabpanel" aria-labelledby="tabCal" hidden>
<section class="scheda" id="sezioneCalibrazione">
<h2>Calibrazione</h2>
<div class="stato" style="margin:0 0 10px">Sorgente: <strong id="calSorgente">-</strong> (si cambia nella tab Acquisizione)</div>
<div class="origine">
<label for="origSorgente"><input id="origSorgente" name="origine" type="radio" value="sorgente" checked> Sorgente selezionata</label>
<label for="origFile"><input id="origFile" name="origine" type="radio" value="file"> Immagine da file</label>
</div>
<div class="riga" style="margin-top:4px">
<button id="scegliFile" class="secondario">Scegli immagine...</button>
<input id="file" type="file" accept="image/*" hidden>
<span id="nomeFile" class="nota" style="margin:0 0 6px">nessuna immagine scelta</span>
</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.
Calcolando dalla sorgente l'anteprima si sospende e poi riprende.</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 class="scorri"><table id="calibrazioni" class="elenco"></table></div>
<div id="msgElenco" class="messaggio"></div>
</section>
</div>
</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
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", "");
dire("msgElenco", ""); // #91
aggiornaStato();
caricaParametri();
if (era) avviaAnteprima();
}
// ---- Anteprima (#87)
var A = { attiva: false, gen: 0, inVolo: false, url: null, nitMax: 0, ris: null }; // #91: ris = {w, h} dell'ultimo frame
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;
}
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(); }
}
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 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"; };
$("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, 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;
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";
} else if (!r.ok) {
stato.textContent = await dettaglio(r);
} else {
var d = await r.json();
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: " + 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.";
}
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 cella(tr, testo) {
var td = document.createElement("td");
td.textContent = testo;
tr.appendChild(td);
return td;
}
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 = [];
if (!C.elenco.length) {
var trVuota = document.createElement("tr");
cella(trVuota, "Nessuna calibrazione salvata.");
t.appendChild(trVuota);
return;
}
var testa = document.createElement("tr");
["Nome", "Risoluzione", "px/mm", "Metodo", "Data", "Origine", ""].forEach(function (h) {
var th = document.createElement("th");
th.textContent = h;
testa.appendChild(th);
});
t.appendChild(testa);
C.elenco.forEach(function (c) {
var tr = document.createElement("tr");
var tdNome = cella(tr, c.nome);
var attiva = C.attiva !== null && c.nome === C.attiva;
if (attiva) {
var s1 = document.createElement("span");
s1.className = "segno";
s1.textContent = "attiva";
tdNome.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;
tdNome.appendChild(s2);
C.celle.push({ w: Number(c.image_width), h: Number(c.image_height), span: s2 });
cella(tr, c.image_width && c.image_height ? fmt(c.image_width, 0) + "×" + fmt(c.image_height, 0) : "-");
cella(tr, fmt(c.px_per_mm, 3));
cella(tr, c.metodo === "densa" ? "densa" : "planare");
cella(tr, c.calibrated_at ? dataItaliana(c.calibrated_at) : "-");
cella(tr, descriviOrigine(c.origine));
var az = document.createElement("td");
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", scollega));
az.appendChild(bottone("Elimina", "secondario", function (e) { eliminaNome(c.nome, e.currentTarget); }));
tr.appendChild(az);
t.appendChild(tr);
});
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));
});
}
// «Carica»: la calibrazione diventa quella attiva della sorgente. Un errore (es. risoluzione diversa) non ferma l'anteprima.
async function caricaNome(nome) {
var sorgente = $("sorgente").value;
if (!sorgente) { dire("msgElenco", "Scegliere una sorgente nella tab Acquisizione.", "errore"); return; }
dire("msgElenco", "Caricamento di «" + nome + "»...", "");
try {
var r = await fetch("/camera/calibrazione-attiva", { method: "PUT", headers: JSON_H, body: JSON.stringify({ source: sorgente, nome: nome }) });
if (sorgente !== $("sorgente").value) return;
if (!r.ok) { dire("msgElenco", await dettaglio(r), "errore"); return; }
dire("msgElenco", "Calibrazione «" + nome + "» caricata sulla sorgente.", "ok");
$("raddrizzato").textContent = $("grezzo").checked ? "non raddrizzata" : "raddrizzata dalla calibrazione"; // il prossimo frame conferma
aggiornaStato();
} catch (e) {
dire("msgElenco", "Servizio non raggiungibile.", "errore");
}
}
// «Scollega»: la sorgente torna senza calibrazione, la libreria resta
async function scollega() {
var sorgente = $("sorgente").value;
if (!sorgente) return;
try {
var r = await fetch("/camera/calibration?source=" + encodeURIComponent(sorgente), { method: "DELETE" });
dire("msgElenco", r.ok ? "Calibrazione scollegata dalla sorgente (resta fra le salvate)." : await dettaglio(r), r.ok ? "ok" : "errore");
if (r.ok) $("raddrizzato").textContent = "non raddrizzata";
} catch (e) {
dire("msgElenco", "Servizio non raggiungibile.", "errore");
}
aggiornaStato();
}
// «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 daFile = $("origFile").checked;
var sorgente = $("sorgente").value;
if (!daFile && !sorgente) { messaggio("Scegliere una sorgente nella tab Acquisizione.", "errore"); return; }
var lato = leggiNumero($("quadrato").value);
if (!(lato > 0)) { messaggio("Il lato dello scacco deve essere un numero maggiore di zero.", "errore"); return; }
var f = new FormData();
f.append("square_mm", String(lato)); // al servizio sempre con il punto
if (daFile) {
var file = $("file").files[0];
if (!file) { messaggio("Scegliere un file immagine.", "errore"); return; }
f.append("image", file);
} else {
f.append("source", sorgente);
}
var eraAttiva = !daFile && A.attiva; // #87: da sorgente l'anteprima si sospende e poi riprende
if (eraAttiva) fermaAnteprima();
messaggio("Calcolo in corso...", "");
["acquisisci", "salva"].forEach(function (i) { $(i).disabled = true; });
try {
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;
if (eraAttiva) avviaAnteprima();
}
}
// «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(); };
// #87: il pulsante del file e' in italiano, l'input nativo resta nascosto
$("scegliFile").onclick = function () { $("file").click(); };
$("file").onchange = function () {
$("nomeFile").textContent = $("file").files[0] ? $("file").files[0].name : "nessuna immagine scelta";
if ($("file").files[0]) $("origFile").checked = true; // #91: scelto un file, l'origine diventa il file
};
$("sorgente").onchange = function () { cambioSorgente(true); };
scegliTab(memoria("tmf.acq.tab") === "cal" ? "cal" : "acq", false); // #91: tab ricordata
caricaSorgenti();
</script>
</body>
</html>
"""