Unisce lavoro/daitarn-tmf-v-camera: visore standard <tmf-viewer> (Refs #122)

This commit is contained in:
2026-10-28 22:06:44 +01:00
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, scelta nella tab Acquisizione. Sorgente in cima alla tab; riquadri «Immagine» (grezza) e,
con la casella, «Calibrata» (/camera/anteprima/calibrata) affiancati; il ciclo anteprima con la casella, «Calibrata» (/camera/anteprima/calibrata) affiancati; il ciclo anteprima
si sospende mentre la tab Calibrazione e' aperta. 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> PAGINA = r"""<!doctype html>
@@ -66,9 +68,7 @@ PAGINA = r"""<!doctype html>
/* anteprima */ /* anteprima */
.palco { background: #020617; border: 1px solid var(--bordo); border-radius: 6px; min-height: 340px; .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; } 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%; } .mirino { position: absolute; inset: 0; pointer-events: none; /* #92: classe, vale per i due riquadri; #122: sta dentro lo stage del tmf-viewer */
.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 */
background: linear-gradient(#fbbf24, #fbbf24) center / 2px 100% no-repeat, background: linear-gradient(#fbbf24, #fbbf24) center / 2px 100% no-repeat,
linear-gradient(#fbbf24, #fbbf24) center / 100% 2px no-repeat; opacity: .85; } linear-gradient(#fbbf24, #fbbf24) center / 100% 2px no-repeat; opacity: .85; }
#vuoto, #vuotoCal, #vuotoCalTab { padding: 30px 16px; color: var(--secondario); text-align: center; } #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 { font-size: .88rem; font-weight: 600; }
.riquadro .titolo span { font-weight: 400; font-size: .78rem; color: var(--secondario); margin-left: 6px; } .riquadro .titolo span { font-weight: 400; font-size: .78rem; color: var(--secondario); margin-left: 6px; }
.riquadro .palco { margin-top: 4px; } .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 */ /* #122: i visori sono tmf-viewer (static/js/image_viewer.js servito su /visore/): barra, zoom e pan vengono dal componente.
.visore { position: relative; margin-top: 10px; } Altezza data dalla pagina (come l'altezza massima che avevano le immagini); [hidden] vale gia' sopra */
.riquadro .visore { margin-top: 4px; } .palco tmf-viewer { width: 100%; height: max(340px, calc(100vh - 330px)); }
.visore > .palco { margin-top: 0; } .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) */
.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; }
.sorgente-riga { display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-end; } .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 .campo-sorgente { flex: 1 1 260px; min-width: 0; }
.sorgente-riga select { width: 100%; } .sorgente-riga select { width: 100%; }
@@ -128,7 +117,7 @@ PAGINA = r"""<!doctype html>
@media (max-width: 900px) { @media (max-width: 900px) {
.griglia { grid-template-columns: minmax(0, 1fr); } .griglia { grid-template-columns: minmax(0, 1fr); }
.riquadri.doppio { grid-template-columns: minmax(0, 1fr); } /* #92: in colonna */ .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) */ @media (max-width: 500px) { /* #91: utilizzabile anche stretta (400 px) */
main { padding: 8px; } main { padding: 8px; }
@@ -179,29 +168,19 @@ PAGINA = r"""<!doctype html>
<div class="riquadri" id="riquadri"> <div class="riquadri" id="riquadri">
<div class="riquadro"> <div class="riquadro">
<div class="titolo">Immagine<span>grezza, senza correzione</span></div> <div class="titolo">Immagine<span>grezza, senza correzione</span></div>
<div class="visore"> <!-- #111 --> <div class="palco"> <!-- #122: tmf-viewer al posto del visore #111; width/height = risoluzione della camera, messe da tornaAdatta() -->
<div class="palco">
<div id="vuoto">Anteprima ferma. Premere «Avvia anteprima» o «Scatta».</div> <div id="vuoto">Anteprima ferma. Premere «Avvia anteprima» o «Scatta».</div>
<div class="cornice" id="cornice" hidden> <tmf-viewer id="immagine" save-name="grezza" title-save="Salva l'immagine grezza" hidden></tmf-viewer>
<img id="immagine" alt="Anteprima grezza della sorgente"> <div id="mirino" class="mirino" hidden></div> <!-- #122: spostato nello stage del componente da creaVisore() -->
<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>
</div> </div>
</div> </div>
<div class="riquadro" id="riquadroCal" hidden> <div class="riquadro" id="riquadroCal" hidden>
<div class="titolo">Calibrata<span id="nomeCalibrata"></span></div> <div class="titolo">Calibrata<span id="nomeCalibrata"></span></div>
<div class="visore"> <!-- #111 --> <div class="palco"> <!-- #122 -->
<div class="palco">
<div id="vuotoCal">Premere «Avvia anteprima» o «Scatta».</div> <div id="vuotoCal">Premere «Avvia anteprima» o «Scatta».</div>
<div class="cornice" id="corniceCal" hidden> <tmf-viewer id="immagineCal" save-name="calibrata" title-save="Salva l'immagine calibrata" hidden></tmf-viewer>
<img id="immagineCal" alt="Anteprima della sorgente raddrizzata dalla calibrazione">
<div id="mirinoCal" class="mirino" hidden></div> <div id="mirinoCal" class="mirino" hidden></div>
</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 --> <!-- #93: scelta della calibrazione da attivare sulla sorgente, con CONFERMA -->
<div class="riga scelta-cal"> <div class="riga scelta-cal">
<select id="sceltaCal" aria-label="Calibrazione da attivare" disabled></select> <select id="sceltaCal" aria-label="Calibrazione da attivare" disabled></select>
@@ -260,14 +239,9 @@ PAGINA = r"""<!doctype html>
<h2>Calibrazione</h2> <h2>Calibrazione</h2>
<div class="stato" style="margin:0 0 10px">Sorgente: <strong id="calSorgente">-</strong> — immagine grezza dalla tab Acquisizione</div> <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 --> <!-- #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"> <!-- #122 -->
<div class="palco" style="margin-top:0">
<div id="vuotoCalTab">Nessuna immagine: «Scatta» o avviare l'anteprima nella tab Acquisizione</div> <div id="vuotoCalTab">Nessuna immagine: «Scatta» o avviare l'anteprima nella tab Acquisizione</div>
<div class="cornice" id="corniceCalTab" hidden> <tmf-viewer id="immagineCalTab" save-name="calibrazione" title-save="Salva l'immagine della calibrazione" hidden></tmf-viewer>
<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>
</div> </div>
<div class="riga" style="margin-top:8px"> <div class="riga" style="margin-top:8px">
<button id="scattaCal" class="secondario" type="button">Scatta</button> <button id="scattaCal" class="secondario" type="button">Scatta</button>
@@ -294,7 +268,7 @@ PAGINA = r"""<!doctype html>
<button id="salva">Salva</button> <button id="salva">Salva</button>
</div> </div>
<div id="msgSalva" class="messaggio"></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> </div>
</section> </section>
@@ -323,6 +297,10 @@ PAGINA = r"""<!doctype html>
</div> </div>
</main> </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> <script>
"use strict"; "use strict";
var $ = function (id) { return document.getElementById(id); }; 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 // #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 }; 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 // ---- #122: visori = tmf-viewer (zoom, Adatta, Salva, pan e Ctrl+rotella vengono dal componente, come nel resto di TMF)
// z = pixel CSS per pixel di camera (100 % = pixel della camera), limiti [zFit, ZMAX]; le dimensioni del frame pieno sono A.ris // width/height del componente = risoluzione del frame pieno (A.ris): 100 % = pixel della camera, anche se l'anteprima scaricata e' piu' piccola
var ZMAX = 8, PASSO = 1.25;
var V = {}; var V = {};
var sondaAlt = null; // #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
// #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
function tornaAdatta() { function tornaAdatta() {
Object.keys(V).forEach(function (k) { Object.keys(V).forEach(function (k) {
var v = V[k]; var el = V[k].el;
v.adatta = true; if (A.ris) { el.setAttribute("width", A.ris.w); el.setAttribute("height", A.ris.h); }
v.z = 0; else { el.removeAttribute("width"); el.removeAttribute("height"); }
v.palco.classList.remove("zoomata", "panning"); if (el.fit) el.fit();
v.palco.style.height = v.cornice.style.width = v.cornice.style.height = "";
v.palco.scrollLeft = v.palco.scrollTop = 0;
applica(v);
}); });
} }
// #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() { function latoAnteprima() {
var zm = 0; var zm = 0;
["acq", "cal"].forEach(function (k) { ["acq", "cal"].forEach(function (k) {
var v = V[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; 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)))); 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) { function creaVisore(chiave, idViewer, idMirino) {
var cornice = $(idCornice); var el = $(idViewer);
var v = { cornice: cornice, img: $(idImg), palco: cornice.parentNode, pct: $("zoomPct" + suf), z: 0, adatta: true, pan: null }; var v = { el: el, z: 0, fit: 0 };
V[chiave] = v; V[chiave] = v;
$("zoomMeno" + suf).onclick = function () { zoomBy(v, 1 / PASSO); }; el.addEventListener("viewer-zoom", function (ev) { v.z = ev.detail.zoom; v.fit = ev.detail.fit; });
$("zoomPiu" + suf).onclick = function () { zoomBy(v, PASSO); }; // #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
$("zoomAdatta" + suf).onclick = function () { adattaVisore(v); }; var monta = function () {
// #111: Ctrl+rotella = zoom; senza Ctrl la rotella scorre la pagina var stage = el.querySelector(".tmfv-stage"), m = idMirino ? $(idMirino) : null;
v.palco.addEventListener("wheel", function (ev) { if (stage && m && m.parentNode !== stage) stage.appendChild(m);
if (!ev.ctrlKey) return; };
ev.preventDefault(); monta();
zoomBy(v, ev.deltaY < 0 ? PASSO : 1 / PASSO); el.addEventListener("viewer-load", monta);
}, { 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); });
} }
creaVisore("acq", "", "cornice", "immagine"); creaVisore("acq", "immagine", "mirino");
creaVisore("cal", "Cal", "corniceCal", "immagineCal"); creaVisore("cal", "immagineCal", "mirinoCal");
creaVisore("tab", "CalTab", "corniceCalTab", "immagineCalTab"); creaVisore("tab", "immagineCalTab", "");
function azzeraIndicatori() { function azzeraIndicatori() {
A.nitMax = 0; A.nitMax = 0;
@@ -653,17 +545,17 @@ function pulsantiAnteprima() {
function mostraImmagine(blob) { function mostraImmagine(blob) {
var vecchio = A.url; var vecchio = A.url;
A.url = URL.createObjectURL(blob); 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 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; $("vuoto").hidden = true;
if (!A.bloccata) mostraInTabCal(A.url); // #94: lo stesso blob URL va anche nella tab Calibrazione, salvo immagine bloccata 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) // #94: riquadro «immagine viva» della tab Calibrazione (url null = vuoto)
function mostraInTabCal(url) { function mostraInTabCal(url) {
$("immagineCalTab").src = url || ""; if (url) $("immagineCalTab").setAttribute("src", url); else $("immagineCalTab").removeAttribute("src"); // #122
$("corniceCalTab").hidden = !url; $("immagineCalTab").hidden = !url;
$("vuotoCalTab").hidden = !!url; $("vuotoCalTab").hidden = !!url;
} }
@@ -671,14 +563,14 @@ function mostraInTabCal(url) {
function mostraCalibrata(blob, nome, soloScala) { function mostraCalibrata(blob, nome, soloScala) {
var vecchio = A.urlCal; var vecchio = A.urlCal;
A.urlCal = URL.createObjectURL(blob); A.urlCal = URL.createObjectURL(blob);
$("immagineCal").src = A.urlCal; $("immagineCal").setAttribute("src", A.urlCal); // #122
if (vecchio) URL.revokeObjectURL(vecchio); if (vecchio) URL.revokeObjectURL(vecchio);
$("corniceCal").hidden = false; $("immagineCal").hidden = false;
$("vuotoCal").hidden = true; $("vuotoCal").hidden = true;
$("nomeCalibrata").textContent = (nome ? "«" + nome + "»" : "") + (soloScala ? " — solo scala, nessuna correzione ottica" : ""); // #95: senza mappa il frame e' grezzo $("nomeCalibrata").textContent = (nome ? "«" + nome + "»" : "") + (soloScala ? " — solo scala, nessuna correzione ottica" : ""); // #95: senza mappa il frame e' grezzo
} }
function messaggioCalibrata(testo) { function messaggioCalibrata(testo) {
$("corniceCal").hidden = true; $("immagineCal").hidden = true;
$("vuotoCal").hidden = false; $("vuotoCal").hidden = false;
$("vuotoCal").textContent = testo; $("vuotoCal").textContent = testo;
$("nomeCalibrata").textContent = ""; $("nomeCalibrata").textContent = "";
@@ -686,7 +578,7 @@ function messaggioCalibrata(testo) {
// #92: cambio sorgente: via i due frame, nessuna immagine di un'altra sorgente // #92: cambio sorgente: via i due frame, nessuna immagine di un'altra sorgente
function svuotaRiquadri() { function svuotaRiquadri() {
A.seqCal++; // una risposta calibrata ancora in volo non vale piu' A.seqCal++; // una risposta calibrata ancora in volo non vale piu'
$("cornice").hidden = true; $("immagine").hidden = true;
$("vuoto").hidden = false; $("vuoto").hidden = false;
mostraInTabCal(null); // #94: svuotato anche il riquadro della tab Calibrazione mostraInTabCal(null); // #94: svuotato anche il riquadro della tab Calibrazione
messaggioCalibrata("Premere «Avvia anteprima» o «Scatta»."); 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); if (b && b !== A.url) URL.revokeObjectURL(b);
$("bloccaImg").checked = false; $("bloccaImg").checked = false;
$("scattaCal").disabled = $("scatta").disabled; $("scattaCal").disabled = $("scatta").disabled;
mostraInTabCal($("cornice").hidden ? null : A.url); mostraInTabCal($("immagine").hidden ? null : A.url);
} }
function rilasciaBlocco() { // DELETE idempotente: l'esito non conta function rilasciaBlocco() { // DELETE idempotente: l'esito non conta
var s = A.sorgenteBloccata; var s = A.sorgenteBloccata;
@@ -843,7 +735,7 @@ function rilasciaBlocco() { // DELETE idempotente: l'esito non conta
$("bloccaImg").onchange = async function () { $("bloccaImg").onchange = async function () {
if (!this.checked) { rilasciaBlocco(); return; } if (!this.checked) { rilasciaBlocco(); return; }
var sorgente = $("sorgente").value; var sorgente = $("sorgente").value;
if (!sorgente || $("cornice").hidden) { if (!sorgente || $("immagine").hidden) {
$("bloccaImg").checked = false; $("bloccaImg").checked = false;
dire("msgBlocco", "Nessuna immagine da bloccare: «Scatta» o avviare l'anteprima.", "errore"); dire("msgBlocco", "Nessuna immagine da bloccare: «Scatta» o avviare l'anteprima.", "errore");
return; return;
@@ -1514,7 +1406,7 @@ async function calcola() {
var d = await r.json(); var d = await r.json();
ultimo = { dati: d }; ultimo = { dati: d };
tabella($("tabella"), 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); $("avviso").textContent = avvisi(d);
$("origineCalcolo").textContent = "Origine: " + (d.bloccata ? "immagine bloccata — " : "") + descriviOrigine(d.origine); // #94 $("origineCalcolo").textContent = "Origine: " + (d.bloccata ? "immagine bloccata — " : "") + descriviOrigine(d.origine); // #94
$("risultato").hidden = false; $("risultato").hidden = false;
+18
View File
@@ -45,6 +45,7 @@ Calibrazione a scacchiera (#83, logica in scripts/calibrazione.py).
la libreria resta) la libreria resta)
GET /calibrazione -> pagina HTML autonoma (offline) per fare tutto a mano GET /calibrazione -> pagina HTML autonoma (offline) per fare tutto a mano
GET /acquisizione -> stessa pagina (#87) 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: Acquisizione (#87): anteprima, esposizione/guadagno, settaggi di default:
POST /camera/anteprima json {source, grezzo=false, lato_max=1280 (320..4096), POST /camera/anteprima json {source, grezzo=false, lato_max=1280 (320..4096),
timeout_ms=5000} -> JPEG ridotto (lato lungo <= lato_max, mai ingrandito). 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 import calibrazione # noqa: E402
from calibrazione_pagina import PAGINA # 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",) _TRIGGER = ("continuo",)
_MAX_CALCOLI = 8 # #91: calcoli tenuti in memoria in attesa di un nome _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 _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(): def calibrazione_pagina():
return HTMLResponse(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 return app
+15 -8
View File
@@ -61,16 +61,23 @@ def test_specchiatura_per_sorgente(): # #110
ids = set(re.findall(r'id="([^"]+)"', PAGINA)) ids = set(re.findall(r'id="([^"]+)"', PAGINA))
attesi = {"sorgente", "avvia", "ferma", "scatta", "mirinoCasella", "immagine", attesi = {"sorgente", "avvia", "ferma", "scatta", "mirinoCasella", "immagine",
def test_zoom_visori(): # #111
"nitidezza", "nitidezzaMax", "saturati", "media", "dimensioni", "nitidezza", "nitidezzaMax", "saturati", "media", "dimensioni",
for suf in ("", "Cal", "CalTab"): # tre riquadri: grezza, calibrata, tab Calibrazione
for base in ("zoomMeno", "zoomAdatta", "zoomPiu", "zoomPct"): "parametri", "salvaParametri", "quadrato", "acquisisci", "caricaImmagine", "fileImmagine",
assert base + suf in ids, base + suf
assert "zoomata" in PAGINA "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 # 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 usati = set(re.findall(r'\$\("([^"]+)"\)', PAGINA))
assert PAGINA.count('<div class="barraZoom">') == 3
assert usati <= ids, usati - ids
def test_sezioni_e_testi_italiani(): 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