Pagina camera 8765: i quattro visori diventano tmf-viewer, JS serviti su /visore/

Refs #122

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 22:03:58 +01:00
parent 43ef22079d
commit 84fc1a08f7
4 changed files with 245 additions and 244 deletions
+55 -163
View File
@@ -17,7 +17,9 @@ un'opzione della calibrazione: la calibrazione usa sempre il frame grezzo della
scelta nella tab Acquisizione. Sorgente in cima alla tab; riquadri «Immagine» (grezza) e,
con la casella, «Calibrata» (/camera/anteprima/calibrata) affiancati; il ciclo anteprima
si sospende mentre la tab Calibrazione e' aperta.
Dipende da: niente. E' una stringa, cosi' il pacchetto compilato non ha file dati.
#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>
@@ -66,9 +68,7 @@ PAGINA = r"""<!doctype html>
/* anteprima */
.palco { background: #020617; border: 1px solid var(--bordo); border-radius: 6px; min-height: 340px;
display: flex; align-items: center; justify-content: center; margin-top: 10px; overflow: hidden; }
.cornice { position: relative; display: inline-block; line-height: 0; max-width: 100%; }
.cornice img { max-width: 100%; max-height: calc(100vh - 330px); min-height: 200px; border: 0; margin: 0; }
.mirino { position: absolute; inset: 0; pointer-events: none; /* #92: classe, vale per i due riquadri */
.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; }
@@ -78,21 +78,10 @@ PAGINA = r"""<!doctype html>
.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; }
/* #111: visore zoom. .visore (position:relative) NON scorre: la barra e' sua figlia e resta ferma; a scorrere e' il .palco da zoomato */
.visore { position: relative; margin-top: 10px; }
.riquadro .visore { margin-top: 4px; }
.visore > .palco { margin-top: 0; }
.barraZoom { position: absolute; top: 8px; right: 8px; z-index: 3; display: flex; align-items: center; gap: 4px;
padding: 3px 5px; border-radius: 6px; background: rgba(0, 0, 0, .6); color: #fff; font-size: .8rem; }
.barraZoom button { padding: 2px 9px; font-size: .85rem; background: rgba(255, 255, 255, .15); color: #fff; }
.barraZoom span { min-width: 3.4em; text-align: right; font-variant-numeric: tabular-nums; }
.palco.zoomata + .barraZoom { right: 26px; } /* #111: lascia libera la barra di scorrimento verticale */
.visore:has(.cornice[hidden]) .barraZoom { display: none; } /* #111: barra nascosta finche' la cornice e' nascosta */
/* #111: stato zoomato: palco ad altezza fissa che scorre, cornice a dimensioni esplicite (impostate da JS); centrata con margin:auto solo se piu' piccola */
.palco.zoomata { display: flex; align-items: flex-start; justify-content: flex-start; overflow: auto; cursor: grab; touch-action: none; }
.palco.zoomata.panning { cursor: grabbing; }
.palco.zoomata .cornice { display: block; flex: none; margin: auto; max-width: none; }
.palco.zoomata .cornice img { display: block; width: 100%; height: 100%; max-width: none; max-height: none; min-height: 0; }
/* #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%; }
@@ -128,7 +117,7 @@ PAGINA = r"""<!doctype html>
@media (max-width: 900px) {
.griglia { grid-template-columns: minmax(0, 1fr); }
.riquadri.doppio { grid-template-columns: minmax(0, 1fr); } /* #92: in colonna */
.cornice img { max-height: 60vh; }
.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; }
@@ -179,29 +168,19 @@ PAGINA = r"""<!doctype html>
<div class="riquadri" id="riquadri">
<div class="riquadro">
<div class="titolo">Immagine<span>grezza, senza correzione</span></div>
<div class="visore"> <!-- #111 -->
<div class="palco">
<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>
<div class="cornice" id="cornice" hidden>
<img id="immagine" alt="Anteprima grezza della sorgente">
<div id="mirino" class="mirino" hidden></div>
</div>
</div>
<div class="barraZoom"><button id="zoomMeno" class="secondario" type="button" title="Riduci">&minus;</button><button id="zoomAdatta" class="secondario" type="button" title="Adatta allo spazio">Adatta</button><button id="zoomPiu" class="secondario" type="button" title="Ingrandisci">+</button><span id="zoomPct">100 %</span></div>
<tmf-viewer id="immagine" save-name="grezza" title-save="Salva l'immagine grezza" hidden></tmf-viewer>
<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="visore"> <!-- #111 -->
<div class="palco">
<div class="palco"> <!-- #122 -->
<div id="vuotoCal">Premere «Avvia anteprima» o «Scatta».</div>
<div class="cornice" id="corniceCal" hidden>
<img id="immagineCal" alt="Anteprima della sorgente raddrizzata dalla calibrazione">
<tmf-viewer id="immagineCal" save-name="calibrata" title-save="Salva l'immagine calibrata" hidden></tmf-viewer>
<div id="mirinoCal" class="mirino" hidden></div>
</div>
</div>
<div class="barraZoom"><button id="zoomMenoCal" class="secondario" type="button" title="Riduci">&minus;</button><button id="zoomAdattaCal" class="secondario" type="button" title="Adatta allo spazio">Adatta</button><button id="zoomPiuCal" class="secondario" type="button" title="Ingrandisci">+</button><span id="zoomPctCal">100 %</span></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>
@@ -260,14 +239,9 @@ PAGINA = r"""<!doctype html>
<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="visore" style="margin-top:0"> <!-- #111 -->
<div class="palco" style="margin-top:0">
<div class="palco" style="margin-top:0"> <!-- #122 -->
<div id="vuotoCalTab">Nessuna immagine: «Scatta» o avviare l'anteprima nella tab Acquisizione</div>
<div class="cornice" id="corniceCalTab" hidden>
<img id="immagineCalTab" alt="Immagine grezza della sorgente">
</div>
</div>
<div class="barraZoom"><button id="zoomMenoCalTab" class="secondario" type="button" title="Riduci">&minus;</button><button id="zoomAdattaCalTab" class="secondario" type="button" title="Adatta allo spazio">Adatta</button><button id="zoomPiuCalTab" class="secondario" type="button" title="Ingrandisci">+</button><span id="zoomPctCalTab">100 %</span></div>
<tmf-viewer id="immagineCalTab" save-name="calibrazione" title-save="Salva l'immagine della calibrazione" hidden></tmf-viewer>
</div>
<div class="riga" style="margin-top:8px">
<button id="scattaCal" class="secondario" type="button">Scatta</button>
@@ -294,7 +268,7 @@ PAGINA = r"""<!doctype html>
<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">
<tmf-viewer id="overlay" class="angoli" fit-height save-name="angoli" title-save="Salva l'immagine degli angoli"></tmf-viewer> <!-- #122: era un <img> senza zoom -->
</div>
</section>
@@ -323,6 +297,10 @@ PAGINA = r"""<!doctype html>
</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); };
@@ -510,130 +488,44 @@ function cambioSorgente(salva) {
// #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 };
// ---- #111: visori con zoom +/-, Adatta e pan (come il wizard DXF #104), uno per riquadro immagine; ognuno ha il suo zoom
// z = pixel CSS per pixel di camera (100 % = pixel della camera), limiti [zFit, ZMAX]; le dimensioni del frame pieno sono A.ris
var ZMAX = 8, PASSO = 1.25;
// ---- #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 = {};
var sondaAlt = null;
// #111: altezza massima del palco = max-height che il CSS da' alle immagini (letta da una sonda nascosta: nessun numero duplicato, segue la media query)
function altezzaMax() {
if (!sondaAlt) {
sondaAlt = document.createElement("div");
sondaAlt.className = "cornice";
sondaAlt.hidden = true;
sondaAlt.appendChild(document.createElement("img"));
document.body.appendChild(sondaAlt);
}
var h = parseFloat(getComputedStyle(sondaAlt.firstChild).maxHeight);
return isFinite(h) && h > 0 ? h : 400;
}
// #111: zoom che fa stare tutto il frame nel palco (0 = non calcolabile: palco nascosto o risoluzione ignota)
function zFit(v) {
var larg = v.palco.offsetWidth - 2 * v.palco.clientLeft; // larghezza utile senza la barra di scorrimento
if (!A.ris || larg <= 0) return 0;
return Math.min(larg / A.ris.w, altezzaMax() / A.ris.h);
}
function zoomata(v) { return v.palco.classList.contains("zoomata"); }
// #111: applica lo stato al DOM. Adatta (z == zFit): nessuna regola nuova, layout di prima. Zoomato: palco ad altezza fissa che scorre, cornice W*z x H*z
function applica(v) {
var zf = zFit(v);
if (!zf) return;
var z = v.adatta ? zf : Math.max(zf, Math.min(ZMAX, v.z));
v.z = z;
if (z > zf * 1.0001) {
v.palco.classList.add("zoomata");
v.palco.style.height = altezzaMax() + "px";
v.cornice.style.width = (A.ris.w * z) + "px";
v.cornice.style.height = (A.ris.h * z) + "px";
} else {
v.palco.classList.remove("zoomata", "panning");
v.palco.style.height = v.cornice.style.width = v.cornice.style.height = "";
}
v.pct.textContent = Math.round(z * 100) + " %";
}
// #111: zoom che tiene fermo il centro visibile (stessa idea del wizard), centro misurato in pixel di camera
function zoomBy(v, f) {
var zf = zFit(v);
if (!zf) return;
var vecchio = v.z || zf;
var z = Math.max(zf, Math.min(ZMAX, vecchio * f));
if (z === vecchio) return;
var p = v.palco, pr = p.getBoundingClientRect(), cr = v.cornice.getBoundingClientRect();
var cx = A.ris.w / 2, cy = A.ris.h / 2;
if (cr.width > 0 && cr.height > 0) {
cx = Math.max(0, Math.min(A.ris.w, (pr.left + p.clientLeft + p.clientWidth / 2 - cr.left) / (cr.width / A.ris.w)));
cy = Math.max(0, Math.min(A.ris.h, (pr.top + p.clientTop + p.clientHeight / 2 - cr.top) / (cr.height / A.ris.h)));
}
v.z = z;
v.adatta = z <= zf * 1.0001;
applica(v);
if (zoomata(v)) { p.scrollLeft = cx * z - p.clientWidth / 2; p.scrollTop = cy * z - p.clientHeight / 2; }
}
function adattaVisore(v) { // #111: nome distinto da adatta(d, v) dei parametri camera
v.adatta = true;
v.z = 0;
applica(v);
v.palco.scrollLeft = 0;
v.palco.scrollTop = 0;
}
// #111: tutti i visori tornano ad Adatta (cambio sorgente, risoluzione del frame diversa); con A.ris ignoto si toglie anche lo stato zoomato
// #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 v = V[k];
v.adatta = true;
v.z = 0;
v.palco.classList.remove("zoomata", "panning");
v.palco.style.height = v.cornice.style.width = v.cornice.style.height = "";
v.palco.scrollLeft = v.palco.scrollTop = 0;
applica(v);
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();
});
}
// #111: lato_max dell'anteprima: 1280 come prima se nessun visore dell'Acquisizione e' zoomato, altrimenti abbastanza pixel per lo zoom piu' alto
// #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.palco.offsetWidth > 0 && zoomata(v) && v.z > zm) zm = v.z;
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, suf, idCornice, idImg) {
var cornice = $(idCornice);
var v = { cornice: cornice, img: $(idImg), palco: cornice.parentNode, pct: $("zoomPct" + suf), z: 0, adatta: true, pan: null };
function creaVisore(chiave, idViewer, idMirino) {
var el = $(idViewer);
var v = { el: el, z: 0, fit: 0 };
V[chiave] = v;
$("zoomMeno" + suf).onclick = function () { zoomBy(v, 1 / PASSO); };
$("zoomPiu" + suf).onclick = function () { zoomBy(v, PASSO); };
$("zoomAdatta" + suf).onclick = function () { adattaVisore(v); };
// #111: Ctrl+rotella = zoom; senza Ctrl la rotella scorre la pagina
v.palco.addEventListener("wheel", function (ev) {
if (!ev.ctrlKey) return;
ev.preventDefault();
zoomBy(v, ev.deltaY < 0 ? PASSO : 1 / PASSO);
}, { passive: false });
// #111: pan col puntatore (solo zoomato): scroll del palco di quanto si muove il puntatore
v.palco.addEventListener("pointerdown", function (ev) {
if (!zoomata(v) || (ev.pointerType === "mouse" && ev.button)) return;
ev.preventDefault(); // senza, Chromium manda pointercancel al primo movimento e il pan si ferma
v.pan = { x: ev.clientX, y: ev.clientY, left: v.palco.scrollLeft, top: v.palco.scrollTop };
v.palco.classList.add("panning");
try { v.palco.setPointerCapture(ev.pointerId); } catch (e) { /* puntatore gia' rilasciato */ }
});
v.palco.addEventListener("pointermove", function (ev) {
if (!v.pan) return;
v.palco.scrollLeft = v.pan.left - (ev.clientX - v.pan.x);
v.palco.scrollTop = v.pan.top - (ev.clientY - v.pan.y);
});
var fine = function () { v.pan = null; v.palco.classList.remove("panning"); };
v.palco.addEventListener("pointerup", fine);
v.palco.addEventListener("pointercancel", fine);
// #111: palco ridimensionato (finestra, griglia a due riquadri, tab mostrata): ricalcola zFit e riapplica
if (window.ResizeObserver) new ResizeObserver(function () { applica(v); }).observe(v.palco);
else window.addEventListener("resize", function () { applica(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", "", "cornice", "immagine");
creaVisore("cal", "Cal", "corniceCal", "immagineCal");
creaVisore("tab", "CalTab", "corniceCalTab", "immagineCalTab");
creaVisore("acq", "immagine", "mirino");
creaVisore("cal", "immagineCal", "mirinoCal");
creaVisore("tab", "immagineCalTab", "");
function azzeraIndicatori() {
A.nitMax = 0;
@@ -653,17 +545,17 @@ function pulsantiAnteprima() {
function mostraImmagine(blob) {
var vecchio = A.url;
A.url = URL.createObjectURL(blob);
$("immagine").src = A.url;
$("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
$("cornice").hidden = false;
$("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) {
$("immagineCalTab").src = url || "";
$("corniceCalTab").hidden = !url;
if (url) $("immagineCalTab").setAttribute("src", url); else $("immagineCalTab").removeAttribute("src"); // #122
$("immagineCalTab").hidden = !url;
$("vuotoCalTab").hidden = !!url;
}
@@ -671,14 +563,14 @@ function mostraInTabCal(url) {
function mostraCalibrata(blob, nome, soloScala) {
var vecchio = A.urlCal;
A.urlCal = URL.createObjectURL(blob);
$("immagineCal").src = A.urlCal;
$("immagineCal").setAttribute("src", A.urlCal); // #122
if (vecchio) URL.revokeObjectURL(vecchio);
$("corniceCal").hidden = false;
$("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) {
$("corniceCal").hidden = true;
$("immagineCal").hidden = true;
$("vuotoCal").hidden = false;
$("vuotoCal").textContent = testo;
$("nomeCalibrata").textContent = "";
@@ -686,7 +578,7 @@ function messaggioCalibrata(testo) {
// #92: cambio sorgente: via i due frame, nessuna immagine di un'altra sorgente
function svuotaRiquadri() {
A.seqCal++; // una risposta calibrata ancora in volo non vale piu'
$("cornice").hidden = true;
$("immagine").hidden = true;
$("vuoto").hidden = false;
mostraInTabCal(null); // #94: svuotato anche il riquadro della tab Calibrazione
messaggioCalibrata("Premere «Avvia anteprima» o «Scatta».");
@@ -832,7 +724,7 @@ function sbloccaLocale() { // casella spenta, il riquadro della tab Calibrazion
if (b && b !== A.url) URL.revokeObjectURL(b);
$("bloccaImg").checked = false;
$("scattaCal").disabled = $("scatta").disabled;
mostraInTabCal($("cornice").hidden ? null : A.url);
mostraInTabCal($("immagine").hidden ? null : A.url);
}
function rilasciaBlocco() { // DELETE idempotente: l'esito non conta
var s = A.sorgenteBloccata;
@@ -843,7 +735,7 @@ function rilasciaBlocco() { // DELETE idempotente: l'esito non conta
$("bloccaImg").onchange = async function () {
if (!this.checked) { rilasciaBlocco(); return; }
var sorgente = $("sorgente").value;
if (!sorgente || $("cornice").hidden) {
if (!sorgente || $("immagine").hidden) {
$("bloccaImg").checked = false;
dire("msgBlocco", "Nessuna immagine da bloccare: «Scatta» o avviare l'anteprima.", "errore");
return;
@@ -1514,7 +1406,7 @@ async function calcola() {
var d = await r.json();
ultimo = { dati: d };
tabella($("tabella"), d);
$("overlay").src = "data:image/png;base64," + d.overlay_png;
$("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;
+18
View File
@@ -45,6 +45,7 @@ Calibrazione a scacchiera (#83, logica in scripts/calibrazione.py).
la libreria resta)
GET /calibrazione -> pagina HTML autonoma (offline) per fare tutto a mano
GET /acquisizione -> stessa pagina (#87)
GET /visore/{image_viewer.js|save_image.js|vision_overlay.js} -> i JS del <tmf-viewer> del frontend (#122), altri nomi 404
Acquisizione (#87): anteprima, esposizione/guadagno, settaggi di default:
POST /camera/anteprima json {source, grezzo=false, lato_max=1280 (320..4096),
timeout_ms=5000} -> JPEG ridotto (lato lungo <= lato_max, mai ingrandito).
@@ -125,6 +126,12 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
import calibrazione # noqa: E402
from calibrazione_pagina import PAGINA # noqa: E402
# #122: i tre file JS del frontend che la pagina carica su /visore/<nome> (solo questi nomi: nessun percorso arbitrario dall'URL).
# Cartelle cercate nell'ordine: pacchetto Nuitka (static/ accanto al modulo, copiata da build.ps1) e sorgenti (src/frontend/flask_app/static/js)
_FILE_VISORE = ("image_viewer.js", "save_image.js", "vision_overlay.js")
_QUI = Path(__file__).resolve().parent
_CARTELLE_VISORE = [_QUI / "static" / "js", _QUI.parent / "src" / "frontend" / "flask_app" / "static" / "js"]
_TRIGGER = ("continuo",)
_MAX_CALCOLI = 8 # #91: calcoli tenuti in memoria in attesa di un nome
_CACHE_CAMERE_S = 60.0 # #92: durata della cache della ricerca camere
@@ -780,6 +787,17 @@ def create_app(folder: Path | str | None = None,
def calibrazione_pagina():
return HTMLResponse(PAGINA)
@app.get("/visore/{nome}")
def visore_file(nome: str):
"""#122: i file JS del <tmf-viewer> letti dal frontend Flask; la pagina resta offline."""
if nome not in _FILE_VISORE: # lista chiusa: niente percorsi dall'URL
raise HTTPException(404, "file non trovato")
for cartella in _CARTELLE_VISORE:
f = cartella / nome
if f.is_file():
return Response(f.read_bytes(), media_type="application/javascript", headers={"Cache-Control": "no-cache"})
raise HTTPException(404, "file non trovato")
return app
+15 -8
View File
@@ -61,16 +61,23 @@ def test_specchiatura_per_sorgente(): # #110
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
attesi = {"sorgente", "avvia", "ferma", "scatta", "mirinoCasella", "immagine",
def test_zoom_visori(): # #111
"nitidezza", "nitidezzaMax", "saturati", "media", "dimensioni",
for suf in ("", "Cal", "CalTab"): # tre riquadri: grezza, calibrata, tab Calibrazione
for base in ("zoomMeno", "zoomAdatta", "zoomPiu", "zoomPct"):
assert base + suf in ids, base + suf
assert "zoomata" in PAGINA
"parametri", "salvaParametri", "quadrato", "acquisisci", "caricaImmagine", "fileImmagine",
"mostraCal", "salva", "stato", "statoSorgente"} # #91: carica/elimina sono ora per riga; #92: file e calibrata nell'Acquisizione
assert attesi <= ids, attesi - ids
# ogni id usato dal JS con $("...") esiste nel markup
# la barra e' sorella del palco che scorre (non dentro): resta ferma durante lo scorrimento
assert PAGINA.count('<div class="visore"') == 3
assert PAGINA.count('<div class="barraZoom">') == 3
usati = set(re.findall(r'\$\("([^"]+)"\)', PAGINA))
assert usati <= ids, usati - ids
def test_sezioni_e_testi_italiani():
+84
View File
@@ -0,0 +1,84 @@
"""Test delle rotte /visore/*.js di camera_service e della pagina che le usa (#122).
Uso: python -m pytest scripts/tests/test_visore_rotte.py -q
Dipende da: venv con fastapi, httpx, opencv (come test_camera_service.py).
image_viewer.js lo scrive un altro piano (V1): se manca nei sorgenti il test della rotta usa una cartella temporanea con file finti.
"""
import importlib.util
import re
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
MODULO = Path(__file__).resolve().parents[1] / "camera_service.py"
NOMI = ("image_viewer.js", "save_image.js", "vision_overlay.js")
def _carica():
spec = importlib.util.spec_from_file_location("camera_service", MODULO)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
@pytest.fixture
def client(tmp_path):
mod = _carica()
with TestClient(mod.create_app(tmp_path)) as c:
c.mod = mod
yield c
@pytest.fixture
def cartella_finta(client, tmp_path):
# file finti in una cartella temporanea: la rotta si prova senza dipendere da image_viewer.js del frontend
d = tmp_path / "js"
d.mkdir()
for n in NOMI:
(d / n).write_text("/* finto " + n + " */\n", encoding="utf-8")
client.mod._CARTELLE_VISORE[:] = [d]
return d
@pytest.mark.parametrize("nome", NOMI)
def test_rotta_serve_il_file(client, cartella_finta, nome):
r = client.get("/visore/" + nome)
assert r.status_code == 200
assert r.headers["content-type"].startswith("application/javascript")
assert r.content == (cartella_finta / nome).read_bytes()
@pytest.mark.parametrize("nome", ["altro.js", "camera_service.py", "..%2Fcamera_service.py", "image_viewer.JS", "image_viewer.js.map"])
def test_altri_nomi_danno_404(client, cartella_finta, nome):
assert client.get("/visore/" + nome).status_code == 404
def test_file_mancante_nella_cartella_da_404(client, tmp_path):
client.mod._CARTELLE_VISORE[:] = [tmp_path / "vuota"]
assert client.get("/visore/save_image.js").status_code == 404
def test_cartelle_di_default_sono_static_js_del_frontend(client):
# sviluppo: sorgenti del frontend; pacchetto Nuitka: static/ accanto al modulo (build.ps1 la include)
cartelle = _carica()._CARTELLE_VISORE
assert any(c.parts[-5:] == ("src", "frontend", "flask_app", "static", "js") for c in cartelle)
assert any(c.parts[-2:] == ("static", "js") and c.parent.parent == MODULO.parent for c in cartelle)
@pytest.mark.parametrize("nome", ("save_image.js", "vision_overlay.js", "image_viewer.js"))
def test_file_reali_del_frontend(client, nome):
reale = next((c / nome for c in client.mod._CARTELLE_VISORE if (c / nome).is_file()), None)
if reale is None:
pytest.skip(nome + " non e' ancora nei sorgenti del frontend (lo scrive il piano V1)")
r = client.get("/visore/" + nome)
assert r.status_code == 200 and r.content == reale.read_bytes()
@pytest.mark.parametrize("rotta", ("/acquisizione", "/calibrazione"))
def test_pagina_usa_quattro_visori_e_i_tre_script(client, rotta):
html = client.get(rotta).text
assert html.count("<tmf-viewer") == 4
src = re.findall(r'<script src="(/visore/[^"]+)"', html)
assert src == ["/visore/vision_overlay.js", "/visore/save_image.js", "/visore/image_viewer.js"]
assert "zoomAdatta" not in html