Acquisitore: calibrazione a scacchiera per sorgente, rotte e pagina /calibrazione (#83)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
"""Pagina HTML della calibrazione a scacchiera (servita da camera_service su /calibrazione, #83).
|
||||
|
||||
Cosa fa: una pagina sola, autonoma e offline (niente CDN, JS e CSS inline), in
|
||||
italiano. Parla con le rotte dello stesso servizio (/camera/sources,
|
||||
/camera/calibration, /camera/calibrate).
|
||||
Dipende da: niente. E' una stringa, cosi' il pacchetto compilato non ha file dati.
|
||||
"""
|
||||
|
||||
PAGINA = """<!doctype html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Calibrazione camera</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; margin: 0; background: #f3f4f6; color: #1f2937; }
|
||||
main { max-width: 960px; margin: 0 auto; padding: 16px; }
|
||||
h1 { font-size: 1.4rem; margin: 0 0 12px; }
|
||||
.scheda { background: #fff; border: 1px solid #d1d5db; border-radius: 8px; padding: 14px; margin-bottom: 14px; }
|
||||
label { display: block; font-size: .85rem; margin-bottom: 4px; color: #4b5563; }
|
||||
select, input[type=number] { padding: 6px 8px; font-size: 1rem; border: 1px solid #9ca3af; border-radius: 6px; }
|
||||
.riga { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; }
|
||||
button { padding: 8px 14px; font-size: 1rem; border: 0; border-radius: 6px; background: #2563eb; color: #fff; cursor: pointer; }
|
||||
button.secondario { background: #6b7280; }
|
||||
button.pericolo { background: #b91c1c; }
|
||||
button:disabled { opacity: .5; cursor: default; }
|
||||
.messaggio { margin-top: 10px; font-weight: 600; }
|
||||
.errore { color: #b91c1c; }
|
||||
.ok { color: #15803d; }
|
||||
table { border-collapse: collapse; }
|
||||
td { padding: 3px 14px 3px 0; }
|
||||
td:first-child { color: #4b5563; }
|
||||
img { max-width: 100%; border: 1px solid #d1d5db; border-radius: 6px; margin-top: 8px; }
|
||||
.avviso { color: #92400e; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Calibrazione camera con scacchiera</h1>
|
||||
|
||||
<div class="scheda">
|
||||
<div class="riga">
|
||||
<div><label for="sorgente">Sorgente</label><select id="sorgente"></select></div>
|
||||
<div><label for="cols">Angoli interni in orizzontale</label><input id="cols" type="number" min="2" value="9"></div>
|
||||
<div><label for="rows">Angoli interni in verticale</label><input id="rows" type="number" min="2" value="6"></div>
|
||||
<div><label for="quadrato">Lato del quadrato (mm)</label><input id="quadrato" type="number" min="0.001" step="any" value="2"></div>
|
||||
</div>
|
||||
<p style="font-size:.85rem;color:#4b5563;margin:10px 0 0">
|
||||
Gli angoli interni sono quelli dove si toccano quattro quadrati: una scacchiera di 10 x 7 quadrati ha 9 x 6 angoli.
|
||||
Tenere la scacchiera piana e ferma, ben illuminata.</p>
|
||||
</div>
|
||||
|
||||
<div class="scheda">
|
||||
<div class="riga">
|
||||
<button id="acquisisci">Acquisisci e calcola</button>
|
||||
<div><label for="file">oppure un file immagine</label><input id="file" type="file" accept="image/*"></div>
|
||||
<button id="carica" class="secondario">Carica immagine e calcola</button>
|
||||
</div>
|
||||
<div id="messaggio" class="messaggio"></div>
|
||||
</div>
|
||||
|
||||
<div class="scheda" id="risultato" hidden>
|
||||
<strong>Risultato del calcolo</strong>
|
||||
<table id="tabella"></table>
|
||||
<div id="avviso" class="avviso"></div>
|
||||
<div class="riga" style="margin-top:10px">
|
||||
<button id="salva">Salva per questa sorgente</button>
|
||||
<span style="font-size:.85rem;color:#4b5563">Da camera, salvando si acquisisce e si calcola di nuovo: tenere la scacchiera ferma.</span>
|
||||
</div>
|
||||
<img id="overlay" alt="Angoli trovati sulla scacchiera">
|
||||
</div>
|
||||
|
||||
<div class="scheda">
|
||||
<strong>Calibrazione salvata</strong>
|
||||
<div id="stato">-</div>
|
||||
<div class="riga" style="margin-top:10px">
|
||||
<button id="elimina" class="pericolo" disabled>Elimina</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
"use strict";
|
||||
var $ = function (id) { return document.getElementById(id); };
|
||||
var ultimo = null; // ultimo calcolo, per «Salva»
|
||||
|
||||
function messaggio(testo, classe) {
|
||||
var m = $("messaggio");
|
||||
m.textContent = testo || "";
|
||||
m.className = "messaggio " + (classe || "");
|
||||
}
|
||||
|
||||
function numero(v, cifre) { return Number(v).toFixed(cifre); }
|
||||
|
||||
function righe(d) {
|
||||
return [
|
||||
["Scala", numero(d.px_per_mm, 4) + " px/mm (" + numero(d.mm_per_px, 5) + " mm/px)"],
|
||||
["Errore di riproiezione (RMS)", numero(d.rms_px, 3) + " px"],
|
||||
["Variazione di scala nell'area", numero(d.scale_spread_pct, 2) + " %"],
|
||||
["Angoli trovati", d.n_corners + " (" + d.cols + " x " + d.rows + ")"],
|
||||
["Lato del quadrato", d.square_mm + " mm"],
|
||||
["Immagine", d.image_width + " x " + d.image_height + " 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);
|
||||
});
|
||||
}
|
||||
|
||||
async function dettaglio(risposta) {
|
||||
try { var j = await risposta.json(); return j.detail || JSON.stringify(j); }
|
||||
catch (e) { return "errore " + risposta.status; }
|
||||
}
|
||||
|
||||
async function caricaSorgenti() {
|
||||
var sel = $("sorgente");
|
||||
try {
|
||||
var r = await fetch("/camera/sources");
|
||||
var j = await r.json();
|
||||
(j.sources || []).forEach(function (s) {
|
||||
var o = document.createElement("option");
|
||||
o.value = s.name;
|
||||
o.textContent = s.label || s.name;
|
||||
sel.appendChild(o);
|
||||
});
|
||||
} catch (e) { messaggio("Elenco delle sorgenti non disponibile.", "errore"); }
|
||||
if (!sel.options.length) messaggio("Nessuna sorgente camera trovata.", "errore");
|
||||
aggiornaStato();
|
||||
}
|
||||
|
||||
async function aggiornaStato() {
|
||||
var sorgente = $("sorgente").value;
|
||||
var stato = $("stato");
|
||||
$("elimina").disabled = true;
|
||||
if (!sorgente) { stato.textContent = "-"; return; }
|
||||
var r = await fetch("/camera/calibration?source=" + encodeURIComponent(sorgente));
|
||||
if (r.status === 404) { stato.textContent = "Sorgente non calibrata."; return; }
|
||||
if (!r.ok) { stato.textContent = await dettaglio(r); return; }
|
||||
var d = await r.json();
|
||||
stato.textContent = "";
|
||||
var t = document.createElement("table");
|
||||
tabella(t, d);
|
||||
var quando = document.createElement("div");
|
||||
quando.textContent = "Calibrata il " + d.calibrated_at;
|
||||
stato.appendChild(t);
|
||||
stato.appendChild(quando);
|
||||
$("elimina").disabled = false;
|
||||
}
|
||||
|
||||
function parametri() {
|
||||
return {
|
||||
cols: $("cols").value, rows: $("rows").value, quadrato: $("quadrato").value
|
||||
};
|
||||
}
|
||||
|
||||
async function calcola(conFile, salva) {
|
||||
var sorgente = $("sorgente").value;
|
||||
if (!sorgente) { messaggio("Scegliere una sorgente.", "errore"); return; }
|
||||
var p = parametri();
|
||||
var f = new FormData();
|
||||
f.append("source", sorgente);
|
||||
f.append("cols", p.cols);
|
||||
f.append("rows", p.rows);
|
||||
f.append("square_mm", p.quadrato);
|
||||
f.append("save", salva ? "true" : "false");
|
||||
if (conFile) {
|
||||
var file = $("file").files[0];
|
||||
if (!file) { messaggio("Scegliere un file immagine.", "errore"); return; }
|
||||
f.append("image", file);
|
||||
}
|
||||
messaggio("Calcolo in corso...", "");
|
||||
["acquisisci", "carica", "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 = { conFile: conFile, dati: d };
|
||||
tabella($("tabella"), d);
|
||||
$("overlay").src = "data:image/png;base64," + d.overlay_png;
|
||||
$("avviso").textContent = d.scale_spread_pct > 1
|
||||
? "Attenzione: la scala varia di " + numero(d.scale_spread_pct, 2) + " % nell'area: scacchiera inclinata o ottica non telecentrica."
|
||||
: "";
|
||||
$("risultato").hidden = false;
|
||||
messaggio(d.saved ? "Calibrazione salvata." : "Calcolo riuscito: controllare e salvare.", "ok");
|
||||
if (d.saved) aggiornaStato();
|
||||
} catch (e) {
|
||||
messaggio("Servizio non raggiungibile.", "errore");
|
||||
} finally {
|
||||
["acquisisci", "carica", "salva"].forEach(function (i) { $(i).disabled = false; });
|
||||
}
|
||||
}
|
||||
|
||||
$("acquisisci").onclick = function () { calcola(false, false); };
|
||||
$("carica").onclick = function () { calcola(true, false); };
|
||||
// «Salva» rifa il calcolo con save=true sulla stessa origine (altra acquisizione se da camera)
|
||||
$("salva").onclick = function () { calcola(ultimo ? ultimo.conFile : false, true); };
|
||||
$("elimina").onclick = async function () {
|
||||
var sorgente = $("sorgente").value;
|
||||
if (!confirm("Eliminare la calibrazione salvata di " + sorgente + "?")) return;
|
||||
var r = await fetch("/camera/calibration?source=" + encodeURIComponent(sorgente), { method: "DELETE" });
|
||||
messaggio(r.ok ? "Calibrazione eliminata." : await dettaglio(r), r.ok ? "ok" : "errore");
|
||||
aggiornaStato();
|
||||
};
|
||||
$("sorgente").onchange = aggiornaStato;
|
||||
caricaSorgenti();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
Reference in New Issue
Block a user