From 5723f055c461580746425e08261fc8aee77a23e2 Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Wed, 28 Oct 2026 21:16:11 +0100 Subject: [PATCH] Overlay SVG e helper salva immagine per il pannello visione Refs #121 #119 #118 Co-Authored-By: Claude Sonnet 5.5 --- .../flask_app/static/js/save_image.js | 78 +++++++++++++++++ .../flask_app/static/js/vision_overlay.js | 83 ++++++++++++++++++- 2 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 src/frontend/flask_app/static/js/save_image.js diff --git a/src/frontend/flask_app/static/js/save_image.js b/src/frontend/flask_app/static/js/save_image.js new file mode 100644 index 0000000..0eec167 --- /dev/null +++ b/src/frontend/flask_app/static/js/save_image.js @@ -0,0 +1,78 @@ +/** + * save_image.js - #119: salva il frame grezzo mostrato nel pannello visione come PNG, senza disegni. + * + * Cosa fa: tmfSaveImage(src, baseName) scarica l'immagine `src` (blob: o http), la converte in PNG se serve + * e la salva con nome _.png: «Salva con nome» (showSaveFilePicker) + * dove c'e', altrimenti . Ritorna una Promise (false se annullato o fallito). + * tmfSaveImageName(baseName, date) e' la funzione pura del nome, esportata per i test. + * Come si usa: poi @click="tmfSaveImage(url, 'task_14')". + * Dipende da: fetch, canvas, createImageBitmap e window del browser, toccati solo dentro le funzioni + * (il modulo si carica anche in node, con require, senza questi globali). + * Idea di conversione da image_to_png.js (#107). + */ + +// #119: nome file proposto _.png; baseName ripulito dai caratteri non validi +function tmfSaveImageName(baseName, date) { + var base = String(baseName === undefined || baseName === null ? '' : baseName) + .replace(/[\/:*?"<>|\u0000-\u001f]/g, '_') // caratteri vietati nei nomi file Windows + .replace(/\s+/g, '_') + .replace(/^[._]+|[._]+$/g, ''); // niente punti o underscore ai bordi + if (!base) base = 'immagine'; + var d = date instanceof Date && !isNaN(date.getTime()) ? date : new Date(); + var p = function (n) { return (n < 10 ? '0' : '') + n; }; + var stamp = '' + d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + '-' + + p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds()); + return base + '_' + stamp + '.png'; +} + +// #119: ridisegna su canvas un'immagine non PNG (es. JPEG, BMP) e ritorna il blob PNG +async function tmfBlobToPng(blob) { + var bitmap = await createImageBitmap(blob); + var canvas = document.createElement('canvas'); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + canvas.getContext('2d').drawImage(bitmap, 0, 0); + if (bitmap.close) bitmap.close(); + var png = await new Promise(function (resolve) { canvas.toBlob(resolve, 'image/png'); }); + if (!png) throw new Error('conversione PNG fallita'); + return png; +} + +// #119: scarica `src`, lo porta a PNG e lo salva; true = salvato, false = annullato dall'utente o errore +async function tmfSaveImage(src, baseName) { + try { + var resp = await fetch(src); + if (!resp.ok) return false; + var blob = await resp.blob(); + if (blob.type !== 'image/png') blob = await tmfBlobToPng(blob); + var name = tmfSaveImageName(baseName, new Date()); + if (typeof window !== 'undefined' && typeof window.showSaveFilePicker === 'function') { + var handle = await window.showSaveFilePicker({ + suggestedName: name, + types: [{ description: 'PNG', accept: { 'image/png': ['.png'] } }], + }); + var w = await handle.createWritable(); + await w.write(blob); + await w.close(); + return true; + } + // #119: ripiego senza «Salva con nome» (browser non Chromium, contesto non sicuro): + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = name; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(function () { URL.revokeObjectURL(url); }, 1000); + return true; + } catch (err) { + if (err && err.name === 'AbortError') return false; // #119: annullato dall'utente, non e' un errore + if (typeof console !== 'undefined') console.error('tmfSaveImage:', err); + return false; + } +} + +// #119: globale nel browser, export per il test node +if (typeof window !== 'undefined') { window.tmfSaveImage = tmfSaveImage; window.tmfSaveImageName = tmfSaveImageName; } +if (typeof module !== 'undefined' && module.exports) module.exports = { tmfSaveImage, tmfSaveImageName }; diff --git a/src/frontend/flask_app/static/js/vision_overlay.js b/src/frontend/flask_app/static/js/vision_overlay.js index b5b79eb..6b2b648 100644 --- a/src/frontend/flask_app/static/js/vision_overlay.js +++ b/src/frontend/flask_app/static/js/vision_overlay.js @@ -7,7 +7,8 @@ * con require. * * API: draw(g, overlay, lw) -> {drawn, skipped}; anchor(item) -> {x, y} | null; - * labels(rows, overlay) -> [{key, x, y, text, status}]. + * labels(rows, overlay) -> [{key, x, y, text, status}]; + * svg(overlay, opts) -> stringa di figli SVG (#121, vedi sotto). * Convenzioni (vs-tools): row = y, col = x; angoli in gradi positivi antiorari a video. * Porting da TieVision-Studio frontend/src/comune/overlay.js (verticiRect2, forma) * e frontend/src/editor/misure.js (ancoraOverlay). @@ -129,6 +130,82 @@ var VisionOverlay = (function () { return res; } + // #121: numero per il markup SVG, al piu' 2 decimali (niente stringhe dei dati nel markup) + function n2(v) { return String(Math.round(v * 100) / 100); } + function pos(v, dflt) { return num(v) && v > 0 ? v : dflt; } + + // #121: path SVG di un arco, porting di percorsoArco dello Studio; extent > 0 = antiorario a video + function arcPath(o) { + var ext = Math.max(-359.99, Math.min(359.99, o.extent_deg)); // 360 pieni darebbero start == end + var a0 = rad(o.start_deg); + var a1 = rad(o.start_deg + ext); + var x0 = o.col + o.radius * Math.cos(a0), y0 = o.row - o.radius * Math.sin(a0); + var x1 = o.col + o.radius * Math.cos(a1), y1 = o.row - o.radius * Math.sin(a1); + var large = Math.abs(ext) > 180 ? 1 : 0; + var sweep = ext > 0 ? 0 : 1; // y verso il basso: antiorario a video = sweep-flag 0 + return 'M ' + n2(x0) + ' ' + n2(y0) + ' A ' + n2(o.radius) + ' ' + n2(o.radius) + + ' 0 ' + large + ' ' + sweep + ' ' + n2(x1) + ' ' + n2(y1); + } + + // #121: [tag, attributi geometrici] di una forma, o null se tipo ignoto o dati malformati (stessi criteri di drawShape) + function shapeSvg(o, cross) { + var i, pts; + switch (o && o.type) { + case 'rect2': + if (!allNum(o, 'row', 'col', 'phi_deg', 'length1', 'length2')) return null; + pts = rect2Vertices(o).map(function (v) { return n2(v[0]) + ',' + n2(v[1]); }).join(' '); + return ['polygon', 'points="' + pts + '"']; + case 'segment': + case 'line': + if (!allNum(o, 'row1', 'col1', 'row2', 'col2')) return null; + return ['line', 'x1="' + n2(o.col1) + '" y1="' + n2(o.row1) + '" x2="' + n2(o.col2) + '" y2="' + n2(o.row2) + '"']; + case 'circle': + if (!allNum(o, 'row', 'col', 'radius')) return null; + return ['circle', 'cx="' + n2(o.col) + '" cy="' + n2(o.row) + '" r="' + n2(o.radius) + '"']; + case 'arc': + if (!allNum(o, 'row', 'col', 'radius', 'start_deg', 'extent_deg')) return null; + return ['path', 'd="' + arcPath(o) + '"']; + case 'points': { + if (!validSeries(o)) return null; + var d = []; + for (i = 0; i < o.rows.length; i++) { + var x = o.cols[i], y = o.rows[i]; + d.push('M ' + n2(x - cross) + ' ' + n2(y) + ' L ' + n2(x + cross) + ' ' + n2(y) + + ' M ' + n2(x) + ' ' + n2(y - cross) + ' L ' + n2(x) + ' ' + n2(y + cross)); + } + return ['path', 'd="' + d.join(' ') + '"']; + } + case 'contour': + if (!validSeries(o)) return null; + pts = o.rows.map(function (r, k) { return n2(o.cols[k]) + ',' + n2(r); }).join(' '); + return ['polyline', 'points="' + pts + '"']; // aperta, come draw + default: + return null; + } + } + + // #121: overlay -> figli SVG (senza esterno) in px immagine. Tratto a spessore costante a video + // (vector-effect non-scaling-stroke), croci dei points a semi-braccio costante a video (cross / zoom). + function svg(overlay, opts) { + opts = opts || {}; + var zoom = pos(opts.zoom, 1), lw = pos(opts.lw, 2), cross = pos(opts.cross, 6); + var items = Array.isArray(overlay) ? overlay : []; + var out = []; + for (var k = 0; k < items.length; k++) { + var o = items[k]; + var sh = null; + try { sh = shapeSvg(o, cross / zoom); } catch (e) { sh = null; } // un elemento rotto non ferma gli altri + if (!sh) continue; + var role = o.role; + var isRoi = role === 'roi'; + // solo colori fissi e numeri: nessuna stringa dei dati finisce nel markup + out.push('<' + sh[0] + ' ' + sh[1] + ' fill="none" stroke="' + (COLORS[role] || COLOR_OTHER) + + '" stroke-width="' + n2(isRoi ? lw * 0.75 : lw) + '"' + + (isRoi ? ' stroke-dasharray="6 4"' : '') + ' vector-effect="non-scaling-stroke"/>'); + } + return out.join(''); + } + // #116: punto di ancoraggio dell'etichetta (px immagine), come ancoraOverlay dello Studio function anchor(o) { switch (o && o.type) { @@ -172,6 +249,8 @@ var VisionOverlay = (function () { var pt = null; for (var i = 0; i < order.length && !pt; i++) pt = anchor(order[i]); if (!pt) return; + // #118: valore null/undefined/vuoto = quota non misurata (Number(null) e' 0): nessuna etichetta + if (r.value === null || r.value === undefined || r.value === '' || typeof r.value === 'boolean') return; var v = Number(r.value); if (!isFinite(v)) return; out.push({ @@ -185,7 +264,7 @@ var VisionOverlay = (function () { return out; } - return { draw: draw, anchor: anchor, labels: labels }; + return { draw: draw, anchor: anchor, labels: labels, svg: svg }; })(); if (typeof window !== 'undefined') window.VisionOverlay = VisionOverlay;