Files
TieMeasureFlow/scripts/calibrazione_pagina.py
Adriano 8c361c14ae Visore <tmf-viewer> di TieVision-Station con no-layers su ogni immagine di TMF (#126)
Rotella senza Ctrl = zoom sul punto del mouse su tutte le immagini. image_viewer.js e
vision_overlay.js copiati byte per byte da TieVision-Station (lavoro/daitarn-nolayers b1af911);
attributo no-layers su ogni <tmf-viewer> (task_editor 3, task_execute 1, calibrazione_pagina 4).
Test JS e pagina demo allineati.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019A9CSrh9rfM5WwqKtg8BSE
2026-10-29 13:49:39 +01:00

1526 lines
72 KiB
Python
Raw Permalink 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.
#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.
#122: i quattro visori sono tmf-viewer; i tre file JS del frontend (vision_overlay, save_image, image_viewer)
li serve camera_service su /visore/.
Dipende da: i tre file JS su /visore/ (camera_service). 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; }
.mirino { position: absolute; inset: 0; pointer-events: none; /* #92: classe, vale per i due riquadri; #122: sta dentro lo stage del tmf-viewer */
background: linear-gradient(#fbbf24, #fbbf24) center / 2px 100% no-repeat,
linear-gradient(#fbbf24, #fbbf24) center / 100% 2px no-repeat; opacity: .85; }
#vuoto, #vuotoCal, #vuotoCalTab { 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; }
/* #122: i visori sono tmf-viewer (static/js/image_viewer.js servito su /visore/): barra, zoom e pan vengono dal componente.
Altezza data dalla pagina (come l'altezza massima che avevano le immagini); [hidden] vale gia' sopra */
.palco tmf-viewer { width: 100%; height: max(340px, calc(100vh - 330px)); }
.angoli { display: block; width: 100%; margin-top: 8px; border: 1px solid var(--bordo); border-radius: 6px; } /* #122: immagine degli angoli, alta quanto serve (fit-height) */
.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 */
.palco tmf-viewer { height: max(340px, 60vh); } /* #122: era max-height delle immagini */
}
@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"> <!-- #122: tmf-viewer al posto del visore #111; width/height = risoluzione della camera, messe da tornaAdatta() -->
<div id="vuoto">Anteprima ferma. Premere «Avvia anteprima» o «Scatta».</div>
<tmf-viewer id="immagine" save-name="grezza" title-save="Salva l'immagine grezza" no-layers hidden></tmf-viewer> <!-- #126: no-layers = senza menu dei livelli -->
<div id="mirino" class="mirino" hidden></div> <!-- #122: spostato nello stage del componente da creaVisore() -->
</div>
</div>
<div class="riquadro" id="riquadroCal" hidden>
<div class="titolo">Calibrata<span id="nomeCalibrata"></span></div>
<div class="palco"> <!-- #122 -->
<div id="vuotoCal">Premere «Avvia anteprima» o «Scatta».</div>
<tmf-viewer id="immagineCal" save-name="calibrata" title-save="Salva l'immagine calibrata" no-layers hidden></tmf-viewer> <!-- #126: no-layers = senza menu dei livelli -->
<div id="mirinoCal" class="mirino" hidden></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">&nbsp;</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>
<!-- #110: specchiatura per sorgente, applicata dal servizio a ogni frame -->
<div class="riga" id="specchiaRiga" style="margin-top:10px">
<label style="margin:0 0 6px"><input id="specchiaO" type="checkbox" disabled> Specchia orizzontale</label>
<label style="margin:0 0 6px"><input id="specchiaV" type="checkbox" disabled> Specchia verticale</label>
</div>
<div id="msgSpecchia" class="messaggio"></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>
<!-- #94: immagine viva (stesso frame dell'anteprima) e «Blocca immagine» per calcolare su quella -->
<div class="palco" style="margin-top:0"> <!-- #122 -->
<div id="vuotoCalTab">Nessuna immagine: «Scatta» o avviare l'anteprima nella tab Acquisizione</div>
<tmf-viewer id="immagineCalTab" save-name="calibrazione" title-save="Salva l'immagine della calibrazione" no-layers hidden></tmf-viewer> <!-- #126: no-layers = senza menu dei livelli -->
</div>
<div class="riga" style="margin-top:8px">
<button id="scattaCal" class="secondario" type="button">Scatta</button>
<label style="margin:0 0 6px"><input id="bloccaImg" type="checkbox"> Blocca immagine</label>
</div>
<div id="msgBlocco" class="messaggio"></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 continua anche qui e si può bloccare l'immagine per calcolare su quella.</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>
<tmf-viewer id="overlay" class="angoli" fit-height no-layers save-name="angoli" title-save="Salva l'immagine degli angoli"></tmf-viewer> <!-- #122: era un <img> senza zoom; #126: no-layers -->
</div>
</section>
<!-- #95: calibrazione manuale, mm per pixel impostato a mano (nessuna scacchiera) -->
<section class="scheda" id="sezioneManuale">
<h2>Calibrazione manuale</h2>
<p class="nota" style="margin-top:0">Senza scacchiera: si scrive a mano quanti millimetri vale un pixel. Vale solo come scala, nessuna correzione dell'ottica.</p>
<div class="riga" style="margin-top:8px">
<div><label for="mmPx">mm per pixel</label><input id="mmPx" type="text" inputmode="decimal" size="10" autocomplete="off"></div>
<div class="nota" id="pxMmCalc" style="margin:0 0 8px">= - px/mm</div>
</div>
<div class="riga" style="margin-top:6px">
<div><label for="nomeManuale">Nome</label><input id="nomeManuale" type="text" maxlength="60" size="22" autocomplete="off"></div>
<button id="salvaManuale">Salva</button>
</div>
<div id="notaManuale" class="nota"></div>
<div id="msgManuale" class="messaggio"></div>
</section>
<section class="scheda" id="sezioneSalvate">
<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>
<!-- #122: file del frontend serviti da camera_service su /visore/ (locali: la pagina resta offline); l'ordine conta -->
<script src="/visore/vision_overlay.js"></script>
<script src="/visore/save_image.js"></script>
<script src="/visore/image_viewer.js"></script>
<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) {
if (v === null || v === undefined || v === "") return "-"; // #95: campi null (rms_px, square_mm della manuale) = «-», non 0
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();
if (A.bloccata) rilasciaBlocco(); // #94: casella spenta e DELETE sulla sorgente vecchia (prima di svuotare i riquadri)
azzeraIndicatori();
svuotaRiquadri(); // #92: il frame vecchio non e' della nuova sorgente
tornaAdatta(); // #111: nuova sorgente = visori ad Adatta
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();
caricaOrientamento(); // #110: specchiatura della nuova sorgente
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, bloccata/sorgenteBloccata/urlBlocco/seqBlocco = #94 immagine bloccata per il calcolo (il riquadro della tab Calibrazione non segue piu' il ciclo), lato = ultimo lato_max
var A = { attiva: false, gen: 0, inVolo: false, url: null, urlCal: null, seqCal: 0, bloccata: false, bloccando: 0, sorgenteBloccata: null, urlBlocco: null, seqBlocco: 0, lato: 1280, calAttiva: null, bloccoCal: false, nitMax: 0, ris: null };
// ---- #122: visori = tmf-viewer (zoom, Adatta, Salva, pan e Ctrl+rotella vengono dal componente, come nel resto di TMF)
// width/height del componente = risoluzione del frame pieno (A.ris): 100 % = pixel della camera, anche se l'anteprima scaricata e' piu' piccola
var V = {};
// #122: tutti i visori dell'anteprima tornano ad Adatta con le dimensioni di A.ris (cambio sorgente, risoluzione del frame diversa); A.ris ignoto = dimensioni naturali
function tornaAdatta() {
Object.keys(V).forEach(function (k) {
var el = V[k].el;
if (A.ris) { el.setAttribute("width", A.ris.w); el.setAttribute("height", A.ris.h); }
else { el.removeAttribute("width"); el.removeAttribute("height"); }
if (el.fit) el.fit();
});
}
// #122: lato_max dell'anteprima: 1280 come prima se nessun visore dell'Acquisizione e' ingrandito, altrimenti abbastanza pixel per lo zoom piu' alto (zoom dall'evento viewer-zoom)
function latoAnteprima() {
var zm = 0;
["acq", "cal"].forEach(function (k) {
var v = V[k];
if (v.el.offsetWidth > 0 && v.fit && v.z > v.fit * 1.0001 && v.z > zm) zm = v.z;
});
if (!zm || !A.ris) return 1280;
return Math.min(4096, Math.max(1280, Math.ceil(Math.max(A.ris.w, A.ris.h) * zm * (window.devicePixelRatio || 1))));
}
function creaVisore(chiave, idViewer, idMirino) {
var el = $(idViewer);
var v = { el: el, z: 0, fit: 0 };
V[chiave] = v;
el.addEventListener("viewer-zoom", function (ev) { v.z = ev.detail.zoom; v.fit = ev.detail.fit; });
// #122: il mirino sta dentro lo stage del componente (segue immagine e zoom); lo stage esiste dopo il connectedCallback, si riprova a ogni viewer-load
var monta = function () {
var stage = el.querySelector(".tmfv-stage"), m = idMirino ? $(idMirino) : null;
if (stage && m && m.parentNode !== stage) stage.appendChild(m);
};
monta();
el.addEventListener("viewer-load", monta);
}
creaVisore("acq", "immagine", "mirino");
creaVisore("cal", "immagineCal", "mirinoCal");
creaVisore("tab", "immagineCalTab", "");
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").setAttribute("src", A.url); // #122
if (vecchio && vecchio !== A.urlBlocco) URL.revokeObjectURL(vecchio); // niente memoria che cresce a ogni frame; #94: non quello bloccato
$("immagine").hidden = false;
$("vuoto").hidden = true;
if (!A.bloccata) mostraInTabCal(A.url); // #94: lo stesso blob URL va anche nella tab Calibrazione, salvo immagine bloccata
}
// #94: riquadro «immagine viva» della tab Calibrazione (url null = vuoto)
function mostraInTabCal(url) {
if (url) $("immagineCalTab").setAttribute("src", url); else $("immagineCalTab").removeAttribute("src"); // #122
$("immagineCalTab").hidden = !url;
$("vuotoCalTab").hidden = !!url;
}
// #92: riquadro «Calibrata»: l'immagine raddrizzata oppure il motivo per cui non c'e' (404/409 del servizio)
function mostraCalibrata(blob, nome, soloScala) {
var vecchio = A.urlCal;
A.urlCal = URL.createObjectURL(blob);
$("immagineCal").setAttribute("src", A.urlCal); // #122
if (vecchio) URL.revokeObjectURL(vecchio);
$("immagineCal").hidden = false;
$("vuotoCal").hidden = true;
$("nomeCalibrata").textContent = (nome ? "«" + nome + "»" : "") + (soloScala ? " — solo scala, nessuna correzione ottica" : ""); // #95: senza mappa il frame e' grezzo
}
function messaggioCalibrata(testo) {
$("immagineCal").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'
$("immagine").hidden = true;
$("vuoto").hidden = false;
mostraInTabCal(null); // #94: svuotato anche il riquadro della tab Calibrazione
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, r.headers.get("X-Raddrizzato") === "no"); // #95
} 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(); tornaAdatta(); } // #111: risoluzione diversa = Adatta
}
$("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) {
while (A.bloccando) await pausa(20); // #94: durante «Blocca immagine» nessuna nuova anteprima
if (!A.attiva || g !== A.gen) return;
var t0 = Date.now();
var esito = await unaAnteprima(latoAnteprima()); // #111: 1280 salvo zoom sull'Acquisizione
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 = $("scattaCal").disabled = true; // #94: anche il «Scatta» della tab Calibrazione
dire("msgAnteprima", "Scatto in corso...", "");
while (A.inVolo) await pausa(50);
while (A.bloccando) await pausa(20); // #94: niente scatto mentre si blocca l'immagine
await unaAnteprima(4096);
$("scatta").disabled = false;
$("scattaCal").disabled = A.bloccata; // #94: con l'immagine bloccata resta spento
}
// #94: «Blocca immagine»: il servizio tiene una copia del frame grezzo e «Calcola» usa quella
function sbloccaLocale() { // casella spenta, il riquadro della tab Calibrazione torna a seguire il ciclo
A.bloccata = false;
A.sorgenteBloccata = null;
A.seqBlocco++; // una risposta di blocco ancora in volo non vale piu'
var b = A.urlBlocco;
A.urlBlocco = null;
if (b && b !== A.url) URL.revokeObjectURL(b);
$("bloccaImg").checked = false;
$("scattaCal").disabled = $("scatta").disabled;
mostraInTabCal($("immagine").hidden ? null : A.url);
}
function rilasciaBlocco() { // DELETE idempotente: l'esito non conta
var s = A.sorgenteBloccata;
sbloccaLocale();
dire("msgBlocco", "");
if (s) fetch("/camera/blocca?source=" + encodeURIComponent(s), { method: "DELETE" }).catch(function () { /* niente da fare */ });
}
$("bloccaImg").onchange = async function () {
if (!this.checked) { rilasciaBlocco(); return; }
var sorgente = $("sorgente").value;
if (!sorgente || $("immagine").hidden) {
$("bloccaImg").checked = false;
dire("msgBlocco", "Nessuna immagine da bloccare: «Scatta» o avviare l'anteprima.", "errore");
return;
}
A.bloccata = true; // subito: il riquadro smette di seguire il ciclo
A.sorgenteBloccata = sorgente;
A.urlBlocco = null; // #94: lo si fissa dopo l'attesa, sul frame davvero mostrato
var n = ++A.seqBlocco;
$("scattaCal").disabled = true;
dire("msgBlocco", "Blocco in corso...", "");
A.bloccando++; // #94: il ciclo e «Scatta» non chiedono nuove anteprime finche' la copia non e' fatta
try {
for (var k = 0; A.inVolo && k < 150; k++) await pausa(20); // #94: attende l'anteprima in volo (max 3 s): il frame copiato e' l'ultimo mostrato
if (n !== A.seqBlocco) return; // spenta o cambiata sorgente durante l'attesa: niente copia
if (A.inVolo) { sbloccaLocale(); dire("msgBlocco", "Anteprima ancora in corso: riprovare.", "errore"); return; }
A.urlBlocco = A.url; // #94: il frame mostrato in questo momento e' quello che il servizio copia
mostraInTabCal(A.url);
var r = await fetch("/camera/blocca", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ source: sorgente }) });
if (n !== A.seqBlocco) { // spenta o cambiata sorgente nel frattempo: la copia appena fatta si toglie
if (r.ok) fetch("/camera/blocca?source=" + encodeURIComponent(sorgente), { method: "DELETE" }).catch(function () { /* niente da fare */ });
return;
}
if (!r.ok) { var d = await dettaglio(r); sbloccaLocale(); dire("msgBlocco", d, "errore"); return; }
var b = await r.json();
dire("msgBlocco", "Immagine bloccata " + b.larghezza + "×" + b.altezza + ": «Calcola» usa questa.", "ok");
} catch (e) {
if (n === A.seqBlocco) { sbloccaLocale(); dire("msgBlocco", "Servizio non raggiungibile.", "errore"); }
} finally {
A.bloccando--; // #94: anche su errore o uscita anticipata
}
};
$("avvia").onclick = avviaAnteprima;
$("ferma").onclick = fermaAnteprima;
$("scatta").onclick = scatta;
$("scattaCal").onclick = scatta; // #94
$("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");
}
}
// #110: specchiatura per sorgente; gen scarta le risposte di una sorgente ormai lasciata, dati = ultimo stato noto del servizio
var O = { gen: 0, dati: { orizzontale: false, verticale: false } };
function caselleSpecchia(attive) { // #110: abilita o disabilita le due caselle
$("specchiaO").disabled = $("specchiaV").disabled = !attive;
}
async function caricaOrientamento() { // #110
var sorgente = $("sorgente").value;
var g = ++O.gen;
dire("msgSpecchia", "");
O.dati = { orizzontale: false, verticale: false };
$("specchiaO").checked = $("specchiaV").checked = false;
caselleSpecchia(false); // spente finche' non si sa lo stato (e senza sorgente)
if (!sorgente) return;
try {
var r = await fetch("/camera/orientamento?source=" + encodeURIComponent(sorgente));
if (g !== O.gen) return;
if (!r.ok) { dire("msgSpecchia", await dettaglio(r), "errore"); return; }
var j = await r.json();
if (g !== O.gen) return;
O.dati = { orizzontale: !!j.orizzontale, verticale: !!j.verticale };
$("specchiaO").checked = O.dati.orizzontale;
$("specchiaV").checked = O.dati.verticale;
caselleSpecchia(true);
} catch (e) {
if (g === O.gen) dire("msgSpecchia", "Servizio non raggiungibile.", "errore");
}
}
async function salvaOrientamento() { // #110: onchange di una casella -> PUT
var sorgente = $("sorgente").value;
if (!sorgente) return;
var g = ++O.gen;
var nuovo = { orizzontale: $("specchiaO").checked, verticale: $("specchiaV").checked };
caselleSpecchia(false); // durante la PUT niente altri cambi
dire("msgSpecchia", "");
var rimetti = function () { // #110: casella com'era prima del clic
$("specchiaO").checked = O.dati.orizzontale;
$("specchiaV").checked = O.dati.verticale;
};
try {
var r = await fetch("/camera/orientamento", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source: sorgente, orizzontale: nuovo.orizzontale, verticale: nuovo.verticale })
});
if (g !== O.gen) return; // sorgente cambiata: ci pensa caricaOrientamento
if (!r.ok) {
var d = await dettaglio(r);
if (g !== O.gen) return;
rimetti();
caselleSpecchia(true);
dire("msgSpecchia", d, "errore");
return;
}
var j = await r.json();
if (g !== O.gen) return;
O.dati = nuovo;
caselleSpecchia(true);
if (j.avviso) dire("msgSpecchia", j.avviso, "avviso"); // es. calibrazione da rifare
else dire("msgSpecchia", "Specchiatura salvata.", "ok");
if (!A.attiva) { // anteprima ferma: un frame solo per vedere l'effetto (se e' attiva, il prossimo esce gia' specchiato)
while (A.inVolo) await pausa(50);
if (g === O.gen && !A.attiva) unaAnteprima(A.lato);
}
} catch (e) {
if (g !== O.gen) return;
rimetti();
caselleSpecchia(true);
dire("msgSpecchia", "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;
// #94: il ciclo anteprima non si sospende piu' aprendo la tab Calibrazione (come faceva #92)
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) {
if (d.metodo === "manuale") return [ // #95: solo scala impostata a mano, niente griglia, residui o lato dello scacco
["Scala", fmt(d.px_per_mm, 4) + " px/mm (" + fmt(d.mm_per_px, 5) + " mm/px)"],
["Metodo", "manuale (mm/px impostato a mano, nessuna correzione della distorsione)"],
["Immagine", fmt(d.image_width, 0) + " x " + fmt(d.image_height, 0) + " px"]
];
return [
["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
statoManuale(); // #95: «Salva» della calibrazione manuale spento senza sorgente
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 (Number(d.square_mm) > 0) $("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" || c.metodo === "manuale" ? c.metodo : "planare", // #95: «manuale» mostrato come tale
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);
var bloccata = A.bloccata; // #94: immagine bloccata = calcolo sulla copia del servizio, l'anteprima puo' continuare
if (bloccata) f.append("bloccata", "true");
else fermaAnteprima(); // #92: si ferma il ciclo; sotto si aspetta anche l'eventuale richiesta ancora in volo
messaggio("Calcolo in corso...", "");
["acquisisci", "salva"].forEach(function (i) { $(i).disabled = true; });
try {
while (!bloccata && 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").setAttribute("src", "data:image/png;base64," + d.overlay_png); // #122
$("avviso").textContent = avvisi(d);
$("origineCalcolo").textContent = "Origine: " + (d.bloccata ? "immagine bloccata — " : "") + descriviOrigine(d.origine); // #94
$("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;
}
}
// ---- #95: calibrazione manuale (mm/px a mano)
var M = { nome: null, timer: null }; // secondo clic su «Sovrascrivi» entro 4 s
function statoManuale() {
var s = $("sorgente").value;
$("salvaManuale").disabled = !s;
$("notaManuale").textContent = s ? "" : "Scegliere una sorgente nella tab Acquisizione: serve per registrare la risoluzione dell'immagine.";
}
function aggiornaManuale() {
var v = leggiNumero($("mmPx").value);
$("pxMmCalc").textContent = v > 0 ? "= " + fmt(1 / v, 4) + " px/mm" : "= - px/mm";
}
function annullaSovrascriviManuale() {
clearTimeout(M.timer);
M.timer = null;
M.nome = null;
$("salvaManuale").textContent = "Salva";
}
async function salvaManuale() {
var sorgente = $("sorgente").value;
if (!sorgente) { dire("msgManuale", "Scegliere una sorgente nella tab Acquisizione.", "errore"); return; }
var mm = leggiNumero($("mmPx").value);
if (!(mm >= 0.00001 && mm <= 100)) { dire("msgManuale", "mm/px deve essere un numero positivo (da 0,00001 a 100).", "errore"); return; }
var nome = $("nomeManuale").value.trim();
if (!nome) { dire("msgManuale", "Scrivere un nome per la calibrazione.", "errore"); return; }
if (nome.length > 60) { dire("msgManuale", "Il nome può avere al massimo 60 caratteri.", "errore"); return; }
var sovrascrivi = M.timer !== null && M.nome === nome.toLowerCase(); // secondo clic sullo stesso nome
annullaSovrascriviManuale();
$("salvaManuale").disabled = true;
try {
var r = await fetch("/camera/calibrazioni/manuale", {
method: "POST", headers: JSON_H,
body: JSON.stringify({ source: sorgente, nome: nome, mm_per_px: mm, sovrascrivi: sovrascrivi }) // al servizio sempre il numero, mai il testo con la virgola
});
if (r.status === 409) {
M.nome = nome.toLowerCase();
$("salvaManuale").textContent = "Sovrascrivi «" + nome + "»? Clic ancora";
M.timer = setTimeout(annullaSovrascriviManuale, 4000);
dire("msgManuale", await dettaglio(r), "errore");
return;
}
if (!r.ok) { dire("msgManuale", await dettaglio(r), "errore"); return; }
dire("msgManuale", "Calibrazione «" + nome + "» salvata: per usarla «Carica» o CONFERMA.", "ok");
caricaCalibrazioni();
} catch (e) {
dire("msgManuale", "Servizio non raggiungibile.", "errore");
} finally {
statoManuale();
}
}
$("mmPx").oninput = aggiornaManuale;
$("salvaManuale").onclick = salvaManuale;
$("nomeManuale").onkeydown = function (e) { if (e.key === "Enter") salvaManuale(); };
statoManuale();
$("acquisisci").onclick = calcola;
$("salva").onclick = salvaNome;
$("nomeCal").onkeydown = function (e) { if (e.key === "Enter") salvaNome(); };
$("sorgente").onchange = function () { dire("msgSorgente", ""); cambioSorgente(true); }; // #92
$("specchiaO").onchange = $("specchiaV").onchange = salvaOrientamento; // #110
scegliTab(memoria("tmf.acq.tab") === "cal" ? "cal" : "acq", false); // #91: tab ricordata
caricaSorgenti();
</script>
</body>
</html>
"""