7fa5bacdfe
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
232 lines
9.2 KiB
Python
232 lines
9.2 KiB
Python
"""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).
|
|
#84: calibrazione densa, un solo dato dell'operatore (lato dello scacco in mm):
|
|
scacchiera che riempie il campo, griglia trovata da sola, mappa di distorsione.
|
|
Dipende da: niente. E' una stringa, cosi' il pacchetto compilato non ha file dati.
|
|
"""
|
|
|
|
PAGINA = """<!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="quadrato">Lato dello scacco (mm)</label><input id="quadrato" type="number" min="0.001" step="any" value="2"></div>
|
|
</div>
|
|
<p style="font-size:.85rem;color:#4b5563;margin:10px 0 0">
|
|
Servono una scacchiera che riempie tutto il campo della camera, piana, ferma e ben illuminata, e il lato di uno scacco.
|
|
La griglia si trova da sola; il risultato corregge anche la distorsione dell'ottica.</p>
|
|
</div>
|
|
|
|
<div class="scheda">
|
|
<div class="riga">
|
|
<button id="acquisisci">Acquisisci e calibra</button>
|
|
<div><label for="file">oppure un file immagine</label><input id="file" type="file" accept="image/*"></div>
|
|
<button id="carica" class="secondario">Carica immagine e calibra</button>
|
|
</div>
|
|
<div id="messaggio" class="messaggio"></div>
|
|
</div>
|
|
|
|
<div class="scheda" id="risultato" hidden>
|
|
<strong>Risultato del calcolo</strong>
|
|
<table id="tabella"></table>
|
|
<div id="avviso" class="avviso"></div>
|
|
<div class="riga" style="margin-top:10px">
|
|
<button id="salva">Salva</button>
|
|
<span style="font-size:.85rem;color:#4b5563">Da camera, salvando si acquisisce e si calcola di nuovo: tenere la scacchiera ferma.</span>
|
|
</div>
|
|
<img id="overlay" alt="Angoli trovati sulla scacchiera">
|
|
</div>
|
|
|
|
<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)"],
|
|
["Metodo", d.metodo !== "densa" ? "planare (senza correzione della distorsione)" : "densa (immagine raddrizzata)"],
|
|
["Griglia trovata", d.cols + " x " + d.rows + " angoli (" + d.n_corners + ")"]
|
|
].concat(d.metodo !== "densa" ? [
|
|
["Errore di riproiezione (RMS)", numero(d.rms_px, 3) + " px"],
|
|
["Variazione di scala nell'area", numero(d.scale_spread_pct, 2) + " %"]
|
|
] : [
|
|
["Residuo massimo", numero(d.residuo_max_px, 3) + " px"],
|
|
["Residuo RMS", numero(d.residuo_rms_px, 3) + " px"],
|
|
["Copertura del campo", numero(d.copertura_pct, 1) + " %"]
|
|
]).concat([
|
|
["Lato dello scacco", 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 { quadrato: $("quadrato").value };
|
|
}
|
|
|
|
// avvisi sulla qualita' (solo calibrazione densa): copertura bassa o residuo alto
|
|
function avvisi(d) {
|
|
var a = [];
|
|
if (d.metodo !== "densa") {
|
|
if (d.scale_spread_pct > 1) a.push("Attenzione: la scala varia di " + numero(d.scale_spread_pct, 2) + " % nell'area: scacchiera inclinata o ottica non telecentrica.");
|
|
} else {
|
|
if (d.copertura_pct < 70) a.push("Attenzione: la scacchiera copre solo il " + numero(d.copertura_pct, 1) + " % del campo (meno del 70 %): la correzione ai bordi non e' misurata.");
|
|
if (d.residuo_max_px > 0.5) a.push("Attenzione: residuo massimo " + numero(d.residuo_max_px, 3) + " px (oltre 0,5 px): scacchiera non piana, sfocata o con scacchi irregolari.");
|
|
}
|
|
return a.join(" ");
|
|
}
|
|
|
|
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("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 = avvisi(d);
|
|
$("risultato").hidden = false;
|
|
messaggio(d.saved ? "Calibrazione salvata." : "Calibrazione riuscita: 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>
|
|
"""
|