Compare commits

...

8 Commits

Author SHA1 Message Date
Adriano 34c251aa90 Test blocco: ciclo con latoAnteprima() al posto di 1280. Refs #111
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 10:59:25 +01:00
Adriano c70d29d6d7 Acquisizione: visore zoom, adatta() rinominata adattaVisore (collideva con quella dei parametri camera)
Aggiunto test che vieta funzioni dichiarate due volte nello script della pagina.

Refs #111

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 10:55:09 +01:00
Adriano 96aca975d0 Merge branch 'lavoro/daitarn-tmf-flip' into lavoro/daitarn-tmf-zoom 2026-10-28 10:53:00 +01:00
Adriano b3c56ef5b5 Acquisizione: zoom +/-, Adatta e pan sui tre riquadri immagine, lato anteprima che segue lo zoom
Barra in sovrimpressione fuori dal palco che scorre, Ctrl+rotella, pan col puntatore.

Refs #111

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 10:52:30 +01:00
Adriano 9111eb2741 Orientamento: se il salvataggio fallisce si ripristina lo stato di prima in memoria. Refs #110
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 10:49:46 +01:00
Adriano b46a70347f Merge branch 'lavoro/daitarn-tmf-flip-pagina' into lavoro/daitarn-tmf-flip-unione 2026-10-28 10:43:39 +01:00
Adriano 155abfd15c Acquisizione: caselle di specchiatura orizzontale e verticale per sorgente
Refs #110

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 10:39:49 +01:00
Adriano 2c2d601495 Specchiatura orizzontale/verticale per sorgente nel servizio camera, salvata su disco. Refs #110
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 10:38:49 +01:00
5 changed files with 540 additions and 6 deletions
+234 -2
View File
@@ -78,6 +78,21 @@ 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; }
.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%; }
@@ -164,6 +179,7 @@ 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 id="vuoto">Anteprima ferma. Premere «Avvia anteprima» o «Scatta».</div>
<div class="cornice" id="cornice" hidden>
@@ -171,9 +187,12 @@ PAGINA = r"""<!doctype html>
<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 class="riquadro" id="riquadroCal" hidden>
<div class="titolo">Calibrata<span id="nomeCalibrata"></span></div>
<div class="visore"> <!-- #111 -->
<div class="palco">
<div id="vuotoCal">Premere «Avvia anteprima» o «Scatta».</div>
<div class="cornice" id="corniceCal" hidden>
@@ -181,6 +200,8 @@ PAGINA = r"""<!doctype html>
<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>
@@ -220,6 +241,12 @@ PAGINA = r"""<!doctype html>
<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>
@@ -233,12 +260,15 @@ 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 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>
</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>
@@ -463,6 +493,7 @@ function cambioSorgente(salva) {
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();
@@ -470,6 +501,7 @@ function cambioSorgente(salva) {
dire("msgElenco", ""); // #91
aggiornaStato();
caricaParametri();
caricaOrientamento(); // #110: specchiatura della nuova sorgente
if (era) avviaAnteprima();
}
@@ -478,6 +510,131 @@ 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;
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
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);
});
}
// #111: lato_max dell'anteprima: 1280 come prima se nessun visore dell'Acquisizione e' zoomato, altrimenti abbastanza pixel per lo zoom piu' alto
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 (!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 };
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); });
}
creaVisore("acq", "", "cornice", "immagine");
creaVisore("cal", "Cal", "corniceCal", "immagineCal");
creaVisore("tab", "CalTab", "corniceCalTab", "immagineCalTab");
function azzeraIndicatori() {
A.nitMax = 0;
["nitidezza", "saturati", "media", "dimensioni", "nitidezzaMax"].forEach(function (i) { $(i).textContent = "-"; });
@@ -587,7 +744,7 @@ function aggiornaIndicatori(h) {
$("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(); }
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
}
@@ -632,7 +789,7 @@ async function ciclo(g) {
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(1280);
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));
@@ -793,6 +950,80 @@ async function caricaParametri() {
}
}
// #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 = "";
@@ -1393,6 +1624,7 @@ $("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>
+92 -3
View File
@@ -61,7 +61,17 @@ Acquisizione (#87): anteprima, esposizione/guadagno, settaggi di default:
POST /camera/parametri/salva json {source} -> {source, salvati, file}
scrive <config-dir>/settaggi/<nome>-<sha1>.json; alla prima apertura
della camera nel servizio il file, se c'e', viene ricaricato.
Le calibrazioni stanno in <config-dir>/calibrazioni, le attive in attive.json (default
#110: specchiatura per sorgente, in software (cv2.flip) su OGNI frame letto dalla
sorgente, prima di raddrizzatura/calibrazione (anteprima, grab, calibrazione da camera,
copia bloccata); non tocca l'immagine caricata a mano in /camera/calibrate.
GET /camera/orientamento?source=S -> {source, orizzontale, verticale} (default false/false);
400 se manca source
PUT /camera/orientamento json {source, orizzontale: bool, verticale: bool} -> come GET +
"avviso" (stringa o null: la calibrazione attiva ha una mappa fatta con l'orientamento
di prima e va rifatta). 400 se source manca o i due campi non sono bool. Salva subito
su disco; scarta i frame tenuti (anteprima/blocca).
Le calibrazioni stanno in <config-dir>/calibrazioni, le attive in attive.json, l'orientamento
per sorgente in orientamento.json (#110) (default
%ProgramData%/Tielogic/TieMeasureFlow/acquisitore su Windows, ./acquisitore altrove).
Serve perche' vs-gui ascolta solo su 127.0.0.1 e il container `client` non lo
@@ -87,6 +97,7 @@ camera a chiunque raggiunga la porta: solo su rete isolata.
from __future__ import annotations
import argparse
import json # #110: orientamento.json
import logging
import math # #95: mm/px finito nella calibrazione manuale
import os
@@ -167,6 +178,36 @@ def create_app(folder: Path | str | None = None,
lock_sorgenti: dict = {} # nome sorgente -> Lock che serializza apertura e grab
calcoli: OrderedDict = OrderedDict() # #91: calcolo_id -> (dati, mappa); gli ultimi _MAX_CALCOLI
lock_calcoli = threading.Lock()
file_orientamento = archivio.cartella / "orientamento.json" # #110: {sorgente: {orizzontale, verticale}}
def _carica_orientamenti() -> dict:
"""#110: orientamenti salvati; file assente o rotto -> {} (mai eccezione)."""
if not file_orientamento.is_file():
return {}
try:
dati = json.loads(file_orientamento.read_text(encoding="utf-8"))
if not isinstance(dati, dict):
raise ValueError("non e' un oggetto JSON")
return {n: {"orizzontale": v.get("orizzontale") is True, "verticale": v.get("verticale") is True}
for n, v in dati.items() if isinstance(n, str) and isinstance(v, dict)}
except (OSError, ValueError) as e:
_log.warning("orientamento non caricato (%s): %s", file_orientamento, e)
return {}
orientamenti: dict = _carica_orientamenti() # #110: sorgente -> {"orizzontale": bool, "verticale": bool}; protetto da `lock`
def _specchia(nome: str, frame):
"""#110: applica l'orientamento della sorgente al frame letto; nessuno -> lo stesso oggetto, senza copia."""
with lock:
o = orientamenti.get(nome) or {}
orizz, vert = o.get("orizzontale", False), o.get("verticale", False)
if orizz and vert:
return cv2.flip(frame, -1) # entrambi = rotazione di 180 gradi
if orizz:
return cv2.flip(frame, 1)
if vert:
return cv2.flip(frame, 0)
return frame
def _config() -> dict:
if folder is None:
@@ -317,7 +358,7 @@ def create_app(folder: Path | str | None = None,
def _grab(nome: str, timeout: int):
"""Un frame dalla sorgente (apre e tiene aperta); errori camera -> HTTP."""
return _con_sorgente(nome, lambda s: s.grab(timeout)) # #87: corpo in _con_sorgente
return _con_sorgente(nome, lambda s: _specchia(nome, s.grab(timeout))) # #87: corpo in _con_sorgente; #110: specchiato prima di tutto il resto
@app.post("/camera/grab")
def camera_grab(corpo: dict = Body(default=None)):
@@ -544,7 +585,7 @@ def create_app(folder: Path | str | None = None,
if isinstance(timeout, bool) or not isinstance(timeout, int) or not 1 <= timeout <= 10000:
raise HTTPException(400, "«timeout_ms» deve essere un intero fra 1 e 10000")
def leggi(s): # #92: il frame grezzo si tiene sotto il lock della sorgente
f = s.grab(timeout)
f = _specchia(nome, s.grab(timeout)) # #110: specchiato PRIMA di tenerlo, cosi' blocca/calibrata lo ricevono gia' specchiato
with lock:
grezzi[nome] = f.copy()
return f
@@ -686,6 +727,54 @@ def create_app(folder: Path | str | None = None,
return _con_sorgente(nome, salva)
# ---- #110: specchiatura orizzontale/verticale per sorgente
def _orientamento_di(nome: str) -> dict:
with lock:
o = orientamenti.get(nome) or {}
return {"source": nome, "orizzontale": bool(o.get("orizzontale")), "verticale": bool(o.get("verticale"))}
@app.get("/camera/orientamento")
def orientamento_leggi(source: str = ""):
return _orientamento_di(_sorgente(source))
@app.put("/camera/orientamento")
def orientamento_scrivi(corpo: dict = Body(default=None)):
corpo = corpo if isinstance(corpo, dict) else {}
nome = _sorgente(corpo.get("source"))
orizz, vert = corpo.get("orizzontale"), corpo.get("verticale")
if not isinstance(orizz, bool) or not isinstance(vert, bool):
raise HTTPException(400, "«orizzontale» e «verticale» devono essere true o false")
with lock:
prima = orientamenti.get(nome) or {}
prima_voce = orientamenti.get(nome) # #110: voce di prima (None = non c'era), per il ripristino se il salvataggio fallisce
cambiato = (bool(prima.get("orizzontale")), bool(prima.get("verticale"))) != (orizz, vert)
if orizz or vert:
orientamenti[nome] = {"orizzontale": orizz, "verticale": vert}
else:
orientamenti.pop(nome, None) # tutti false: la sorgente esce dal file
# scrittura atomica di tutto il dizionario (tmp + replace)
try:
file_orientamento.parent.mkdir(parents=True, exist_ok=True)
tmp = file_orientamento.with_name(file_orientamento.name + ".tmp")
tmp.write_text(json.dumps(orientamenti, ensure_ascii=False, indent=1), encoding="utf-8")
os.replace(tmp, file_orientamento)
except OSError as e:
_log.warning("orientamento non salvato (%s): %s", file_orientamento, e)
if prima_voce is None: # #110: salvataggio fallito -> in memoria resta l'orientamento di prima
orientamenti.pop(nome, None)
else:
orientamenti[nome] = prima_voce
raise HTTPException(500, "salvataggio dell'orientamento non riuscito")
# i frame tenuti hanno l'orientamento di prima
grezzi.pop(nome, None)
bloccati.pop(nome, None)
avviso = None
if cambiato and archivio.leggi_mappa(nome) is not None:
avviso = (f"la calibrazione attiva «{archivio.attiva_di(nome)}» corregge la distorsione "
"con l'orientamento di prima: va rifatta")
return {**_orientamento_di(nome), "avviso": avviso}
@app.get("/calibrazione", response_class=HTMLResponse)
@app.get("/acquisizione", response_class=HTMLResponse) # #87: stessa pagina
def calibrazione_pagina():
+2 -1
View File
@@ -151,6 +151,7 @@ def test_pagina_blocco_attende_l_anteprima_in_volo():
assert "k < 150" in blocco and "pausa(20)" in blocco
assert blocco.index("A.inVolo") < blocco.index('fetch("/camera/blocca"')
ciclo = PAGINA.split("async function ciclo")[1].split("function avviaAnteprima")[0]
assert "while (A.bloccando)" in ciclo and ciclo.index("A.bloccando") < ciclo.index("unaAnteprima(1280)")
# #111: il lato dell'anteprima segue lo zoom (latoAnteprima(), 1280 senza zoom)
assert "while (A.bloccando)" in ciclo and ciclo.index("A.bloccando") < ciclo.index("unaAnteprima(latoAnteprima())")
scatta = PAGINA.split("async function scatta")[1].split("// #94: «Blocca immagine»")[0]
assert "while (A.bloccando)" in scatta and scatta.index("A.bloccando") < scatta.index("unaAnteprima(4096)")
+186
View File
@@ -0,0 +1,186 @@
"""Test della specchiatura orizzontale/verticale per sorgente (#110): scripts/camera_service.py.
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
Copre: GET/PUT /camera/orientamento, effetto su /camera/grab e /camera/anteprima,
persistenza al riavvio, file rotto, validazione, scarto dei frame tenuti, avviso
sulla calibrazione densa. Sorgente: camera:folder su un'immagine 4x3 asimmetrica.
"""
import importlib.util
from pathlib import Path
import cv2
import numpy as np
import pytest
from fastapi.testclient import TestClient
from test_calibrazione import ( # calibrazione densa reale (#84), per l'avviso
SORGENTE as SORGENTE_DENSA, cartella_densa, carica_su, client_denso, salva_con_nome,
)
__all__ = ["cartella_densa", "client_denso"] # fixture importate, restano usate
MODULO = Path(__file__).resolve().parents[1] / "camera_service.py"
SORGENTE = "camera:folder"
def _carica():
spec = importlib.util.spec_from_file_location("camera_service_orient", MODULO)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
@pytest.fixture
def cartella(tmp_path):
# #110: 4x3 tutta nera con UN pixel acceso in alto a sinistra (riga 0, colonna 0)
img = tmp_path / "foto"
img.mkdir()
f = np.zeros((3, 4, 3), np.uint8)
f[0, 0] = 255
cv2.imwrite(str(img / "a.png"), f)
return img
@pytest.fixture
def config(tmp_path):
return tmp_path / "cfg"
@pytest.fixture
def client(cartella, config):
mod = _carica()
with TestClient(mod.create_app(cartella, config)) as c:
yield c
def _acceso(png: bytes):
"""(riga, colonna) del pixel acceso nel PNG; controlla che sia uno solo."""
f = cv2.imdecode(np.frombuffer(png, np.uint8), cv2.IMREAD_COLOR)
assert f.shape[:2] == (3, 4)
r, c = np.nonzero(f.max(axis=2) > 128)
assert len(r) == 1
return int(r[0]), int(c[0])
def _imposta(c, orizz, vert, source=SORGENTE):
return c.put("/camera/orientamento", json={"source": source, "orizzontale": orizz, "verticale": vert})
def _grab(c):
r = c.post("/camera/grab", json={"source": SORGENTE})
assert r.status_code == 200, r.text
return _acceso(r.content)
def test_get_senza_file_false_false(client):
r = client.get("/camera/orientamento", params={"source": SORGENTE})
assert r.status_code == 200
assert r.json() == {"source": SORGENTE, "orizzontale": False, "verticale": False}
def test_get_senza_source_400(client):
assert client.get("/camera/orientamento").status_code == 400
@pytest.mark.parametrize("orizz,vert,atteso", [
(False, False, (0, 0)), # originale: alto a sinistra
(True, False, (0, 3)), # orizzontale: alto a destra
(False, True, (2, 0)), # verticale: basso a sinistra
(True, True, (2, 3)), # entrambi: basso a destra (180 gradi)
])
def test_grab_specchiato(client, orizz, vert, atteso):
r = _imposta(client, orizz, vert)
assert r.status_code == 200, r.text
assert (r.json()["orizzontale"], r.json()["verticale"]) == (orizz, vert)
assert client.get("/camera/orientamento", params={"source": SORGENTE}).json()["orizzontale"] is orizz
assert _grab(client) == atteso
def test_tutti_false_toglie_la_sorgente_dal_file(client, config):
_imposta(client, True, False)
assert SORGENTE in (config / "orientamento.json").read_text(encoding="utf-8")
_imposta(client, False, False)
assert SORGENTE not in (config / "orientamento.json").read_text(encoding="utf-8")
assert _grab(client) == (0, 0)
def test_anteprima_specchiata_dimensioni_invariate(client):
_imposta(client, True, True)
r = client.post("/camera/anteprima", json={"source": SORGENTE, "lato_max": 4096})
assert r.status_code == 200, r.text
assert (r.headers["x-larghezza"], r.headers["x-altezza"]) == ("4", "3")
f = cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_GRAYSCALE)
assert f.shape == (3, 4)
# JPEG con perdite: il pixel acceso e' il piu' chiaro, in basso a destra
assert np.unravel_index(int(np.argmax(f)), f.shape) == (2, 3)
def test_persistenza_al_riavvio(cartella, config):
mod = _carica()
with TestClient(mod.create_app(cartella, config)) as c1:
assert _imposta(c1, True, False).status_code == 200
with TestClient(_carica().create_app(cartella, config)) as c2:
j = c2.get("/camera/orientamento", params={"source": SORGENTE}).json()
assert (j["orizzontale"], j["verticale"]) == (True, False)
assert _grab(c2) == (0, 3)
def test_file_rotto_non_blocca_l_avvio(cartella, config):
config.mkdir(parents=True)
(config / "orientamento.json").write_text("{non e json", encoding="utf-8")
with TestClient(_carica().create_app(cartella, config)) as c:
j = c.get("/camera/orientamento", params={"source": SORGENTE}).json()
assert (j["orizzontale"], j["verticale"]) == (False, False)
assert _imposta(c, False, True).status_code == 200 # e si puo' riscrivere
def test_put_campi_non_bool_400(client):
for corpo in ({"source": SORGENTE, "orizzontale": 1, "verticale": False},
{"source": SORGENTE, "orizzontale": True, "verticale": "si"},
{"source": SORGENTE, "orizzontale": True},
{"orizzontale": True, "verticale": True}):
assert client.put("/camera/orientamento", json=corpo).status_code == 400, corpo
def test_put_scarta_i_frame_tenuti(client):
assert client.post("/camera/anteprima", json={"source": SORGENTE}).status_code == 200
assert client.post("/camera/blocca", json={"source": SORGENTE}).status_code == 200
_imposta(client, True, False)
assert client.post("/camera/blocca", json={"source": SORGENTE}).status_code == 404
# col frame tenuto scartato anche l'ultimo grezzo dell'anteprima e' sparito
assert client.post("/camera/anteprima/calibrata", json={"source": SORGENTE}).status_code == 404
def test_avviso_senza_mappa_e_null(client):
j = _imposta(client, True, False).json()
assert j["avviso"] is None
def test_avviso_con_mappa_attiva(client_denso):
densa = client_denso.post("/camera/calibrate", data={"source": SORGENTE_DENSA, "square_mm": "2"}).json()
assert salva_con_nome(client_denso, densa, "Densa").json()["metodo"] == "densa"
assert carica_su(client_denso, SORGENTE_DENSA, "Densa").status_code == 200
r = _imposta(client_denso, True, False, source=SORGENTE_DENSA)
assert r.status_code == 200, r.text
assert "«Densa»" in r.json()["avviso"] and "va rifatta" in r.json()["avviso"]
# stesso valore di nuovo: nulla e' cambiato, nessun avviso
assert _imposta(client_denso, True, False, source=SORGENTE_DENSA).json()["avviso"] is None
def test_scrittura_fallita_500_e_orientamento_di_prima(cartella, config, monkeypatch):
# #110: se orientamento.json non si scrive, in memoria resta l'orientamento di prima
mod = _carica()
with TestClient(mod.create_app(cartella, config)) as c:
assert _imposta(c, True, False).status_code == 200 # stato di partenza: orizzontale
def rotto(*a, **k):
raise OSError("disco pieno")
monkeypatch.setattr(mod.os, "replace", rotto)
assert _imposta(c, False, True).status_code == 500
j = c.get("/camera/orientamento", params={"source": SORGENTE}).json()
assert (j["orizzontale"], j["verticale"]) == (True, False)
assert _grab(c) == (0, 3) # ancora solo orizzontale
# sorgente che prima non c'era: la voce non resta
assert c.put("/camera/orientamento", json={"source": "altra", "orizzontale": True, "verticale": True}).status_code == 500
j = c.get("/camera/orientamento", params={"source": "altra"}).json()
assert (j["orizzontale"], j["verticale"]) == (False, False)
+26
View File
@@ -52,3 +52,29 @@ def test_localstorage_protetto():
# ogni uso di localStorage sta dentro un try
for m in re.finditer(r"localStorage[.]", PAGINA):
assert "try {" in PAGINA[max(0, m.start() - 200):m.start()]
def test_specchiatura_per_sorgente(): # #110
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
assert {"specchiaO", "specchiaV", "msgSpecchia"} <= ids
assert '"/camera/orientamento' in PAGINA
assert "Specchia orizzontale" in PAGINA and "Specchia verticale" in PAGINA
def test_zoom_visori(): # #111
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
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
assert "latoAnteprima()" in PAGINA and "unaAnteprima(1280)" not in PAGINA # lato_max segue lo zoom
# 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
def test_nessuna_funzione_dichiarata_due_volte(): # #111: in JS vince l'ultima dichiarazione, in silenzio
script = re.search(r"<script>(.*?)</script>", PAGINA, re.S).group(1)
nomi = re.findall(r"function\s+([A-Za-z0-9_$]+)\s*\(", script)
doppi = sorted({n for n in nomi if nomi.count(n) > 1})
assert not doppi, "funzioni dichiarate piu' volte: " + ", ".join(doppi)