From b46ccba1ee09544f34399437080883384a15f1f0 Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Wed, 28 Oct 2026 16:52:50 +0100 Subject: [PATCH] Pannello visione: modulo vision_overlay.js (forme overlay, ancore, etichette) con test node e fixture task 14 Refs #116 Co-Authored-By: Claude Sonnet 5.5 --- .../flask_app/static/js/vision_overlay.js | 192 ++++++++ .../js/fixtures/vision_check_task14.json | 448 ++++++++++++++++++ .../flask_app/tests/js/test_vision_overlay.js | 228 +++++++++ .../flask_app/tests/test_vision_overlay_js.py | 35 ++ 4 files changed, 903 insertions(+) create mode 100644 src/frontend/flask_app/static/js/vision_overlay.js create mode 100644 src/frontend/flask_app/tests/js/fixtures/vision_check_task14.json create mode 100644 src/frontend/flask_app/tests/js/test_vision_overlay.js create mode 100644 src/frontend/flask_app/tests/test_vision_overlay_js.py diff --git a/src/frontend/flask_app/static/js/vision_overlay.js b/src/frontend/flask_app/static/js/vision_overlay.js new file mode 100644 index 0000000..b5b79eb --- /dev/null +++ b/src/frontend/flask_app/static/js/vision_overlay.js @@ -0,0 +1,192 @@ +/** + * #116: disegno dell'overlay del check visione sul canvas del pannello operatore e + * ancoraggio delle etichette delle quote alla geometria del loro strumento. + * + * Script classico (niente import/export): lo carica task_execute.html prima di + * vision_execute.js, che lo usa via window.VisionOverlay. In node (test) si carica + * con require. + * + * API: draw(g, overlay, lw) -> {drawn, skipped}; anchor(item) -> {x, y} | null; + * labels(rows, overlay) -> [{key, x, y, text, status}]. + * 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). + */ +var VisionOverlay = (function () { + 'use strict'; + + // #116: colore per ruolo, uguale a quello dello Studio + var COLORS = { reference: '#2563eb', found: '#16a34a', roi: '#f59e0b' }; + var COLOR_OTHER = '#ef4444'; + + function rad(d) { return (d * Math.PI) / 180; } + function num(v) { return typeof v === 'number' && isFinite(v); } + function allNum(o) { + for (var i = 1; i < arguments.length; i++) { + if (!o || !num(o[arguments[i]])) return false; + } + return true; + } + // #116: serie rows/cols valida = entrambe array, stessa lunghezza, non vuote, tutti numeri finiti + function validSeries(o) { + if (!o || !Array.isArray(o.rows) || !Array.isArray(o.cols)) return false; + if (o.rows.length === 0 || o.rows.length !== o.cols.length) return false; + for (var i = 0; i < o.rows.length; i++) { + if (!num(o.rows[i]) || !num(o.cols[i])) return false; + } + return true; + } + + // #116: i 4 vertici del rect2 (x = col, y = row), come verticiRect2 dello Studio + function rect2Vertices(o) { + var a = rad(o.phi_deg); + var u = [Math.cos(a), -Math.sin(a)]; + var v = [-Math.sin(a), -Math.cos(a)]; // u ruotato di +90 antiorario + return [[1, 1], [1, -1], [-1, -1], [-1, 1]].map(function (st) { + return [ + o.col + st[0] * o.length1 * u[0] + st[1] * o.length2 * v[0], + o.row + st[0] * o.length1 * u[1] + st[1] * o.length2 * v[1], + ]; + }); + } + + // #116: disegna una forma; true se disegnata, false se saltata (tipo ignoto o dati malformati) + function drawShape(g, o, lw) { + var type = o && o.type; + var i; + switch (type) { + case 'rect2': { + if (!allNum(o, 'row', 'col', 'phi_deg', 'length1', 'length2')) return false; + var vs = rect2Vertices(o); + g.beginPath(); + g.moveTo(vs[0][0], vs[0][1]); + for (i = 1; i < vs.length; i++) g.lineTo(vs[i][0], vs[i][1]); + g.closePath(); + g.stroke(); + return true; + } + case 'segment': + case 'line': + if (!allNum(o, 'row1', 'col1', 'row2', 'col2')) return false; + g.beginPath(); + g.moveTo(o.col1, o.row1); + g.lineTo(o.col2, o.row2); + g.stroke(); + return true; + case 'circle': + if (!allNum(o, 'row', 'col', 'radius')) return false; + g.beginPath(); + g.arc(o.col, o.row, o.radius, 0, 2 * Math.PI); + g.stroke(); + return true; + case 'arc': + if (!allNum(o, 'row', 'col', 'radius', 'start_deg', 'extent_deg')) return false; + g.beginPath(); + // #116: y canvas verso il basso, quindi angoli negati; extent > 0 = antiorario a video + g.arc(o.col, o.row, o.radius, -rad(o.start_deg), -rad(o.start_deg + o.extent_deg), o.extent_deg > 0); + g.stroke(); + return true; + case 'points': { + if (!validSeries(o)) return false; + var m = Math.max(3, lw * 2); // semi-lato della croce + g.beginPath(); + for (i = 0; i < o.rows.length; i++) { + g.moveTo(o.cols[i] - m, o.rows[i]); + g.lineTo(o.cols[i] + m, o.rows[i]); + g.moveTo(o.cols[i], o.rows[i] - m); + g.lineTo(o.cols[i], o.rows[i] + m); + } + g.stroke(); + return true; + } + case 'contour': + if (!validSeries(o)) return false; + g.beginPath(); + g.moveTo(o.cols[0], o.rows[0]); + for (i = 1; i < o.rows.length; i++) g.lineTo(o.cols[i], o.rows[i]); + g.stroke(); // polilinea aperta, come nello Studio + return true; + default: + return false; + } + } + + function draw(g, overlay, lw) { + var res = { drawn: 0, skipped: 0 }; + var items = Array.isArray(overlay) ? overlay : []; + for (var k = 0; k < items.length; k++) { + var o = items[k]; + var role = o && o.role; + var isRoi = role === 'roi'; + g.strokeStyle = COLORS[role] || COLOR_OTHER; + g.lineWidth = isRoi ? lw * 0.75 : lw; + g.setLineDash(isRoi ? [lw * 3, lw * 2] : []); // roi tratteggiata + var ok = false; + try { ok = drawShape(g, o, lw); } catch (e) { ok = false; } // un elemento rotto non ferma gli altri + if (ok) res.drawn++; else res.skipped++; + } + g.setLineDash([]); + return res; + } + + // #116: punto di ancoraggio dell'etichetta (px immagine), come ancoraOverlay dello Studio + function anchor(o) { + switch (o && o.type) { + case 'segment': + case 'line': + return allNum(o, 'row1', 'col1', 'row2', 'col2') + ? { x: (o.col1 + o.col2) / 2, y: (o.row1 + o.row2) / 2 } : null; + case 'circle': + return allNum(o, 'row', 'col', 'radius') ? { x: o.col + o.radius, y: o.row } : null; // punto a destra + case 'arc': { + if (!allNum(o, 'row', 'col', 'radius', 'start_deg', 'extent_deg')) return null; + var a = rad(o.start_deg + o.extent_deg / 2); // punto medio dell'arco + return { x: o.col + o.radius * Math.cos(a), y: o.row - o.radius * Math.sin(a) }; + } + case 'points': + return Array.isArray(o.rows) && Array.isArray(o.cols) && num(o.rows[0]) && num(o.cols[0]) + ? { x: o.cols[0], y: o.rows[0] } : null; + case 'rect2': + return allNum(o, 'row', 'col') ? { x: o.col, y: o.row } : null; + case 'contour': { + if (!Array.isArray(o.rows) || !Array.isArray(o.cols)) return null; + var i = Math.floor(Math.min(o.rows.length, o.cols.length) / 2); + return num(o.rows[i]) && num(o.cols[i]) ? { x: o.cols[i], y: o.rows[i] } : null; + } + default: + return null; + } + } + + // #116: un'etichetta per riga con vision_output 'tool_id.proprieta' che ha un overlay ancorabile + function labels(rows, overlay) { + var out = []; + var items = Array.isArray(overlay) ? overlay : []; + (rows || []).forEach(function (r) { + if (!r || typeof r.vision_output !== 'string' || r.vision_output.indexOf('.') < 0) return; + var toolId = r.vision_output.split('.')[0]; + var mine = items.filter(function (o) { return o && o.tool_id === toolId; }); + // prima gli elementi 'found', poi qualsiasi altro + var order = mine.filter(function (o) { return o.role === 'found'; }) + .concat(mine.filter(function (o) { return o.role !== 'found'; })); + var pt = null; + for (var i = 0; i < order.length && !pt; i++) pt = anchor(order[i]); + if (!pt) return; + var v = Number(r.value); + if (!isFinite(v)) return; + out.push({ + key: r.key, + x: pt.x, + y: pt.y, + text: r.name + ' ' + v.toFixed(3) + ' ' + (r.unit || ''), + status: r.status, + }); + }); + return out; + } + + return { draw: draw, anchor: anchor, labels: labels }; +})(); + +if (typeof window !== 'undefined') window.VisionOverlay = VisionOverlay; +if (typeof module !== 'undefined' && module.exports) module.exports = VisionOverlay; diff --git a/src/frontend/flask_app/tests/js/fixtures/vision_check_task14.json b/src/frontend/flask_app/tests/js/fixtures/vision_check_task14.json new file mode 100644 index 0000000..4928555 --- /dev/null +++ b/src/frontend/flask_app/tests/js/fixtures/vision_check_task14.json @@ -0,0 +1,448 @@ +{ + "outcome": "pass", + "overlay": [ + { + "type": "points", + "role": "found", + "rows": [ + 2218.926540676052 + ], + "cols": [ + 2371.08496101821 + ], + "tool_id": "blob" + }, + { + "type": "segment", + "role": "found", + "row1": 2218.926540676052, + "col1": 2371.08496101821, + "row2": 2268.925168990387, + "col2": 2371.4553212279457, + "tool_id": "blob" + }, + { + "type": "contour", + "role": "found", + "rows": [ + 2469.0093822012514, + 2467.8907055854274, + 1969.1995500932737, + 1970.3182267090972, + 2469.0093822012514 + ], + "cols": [ + 2297.7334028311207, + 2448.7547459672996, + 2445.0607374080982, + 2294.0393942719193, + 2297.7334028311207 + ], + "tool_id": "blob" + }, + { + "type": "rect2", + "role": "roi", + "row": 2250.0000476834334, + "col": 2296.999963957319, + "phi_deg": -89.99999458063853, + "length1": 159.9999999999998, + "length2": 20.0, + "tool_id": "lato_sx" + }, + { + "type": "segment", + "role": "found", + "row1": 2409.997184964675, + "col1": 2297.344751969946, + "row2": 2089.9983738509072, + "col2": 2295.147617847124, + "tool_id": "lato_sx" + }, + { + "type": "rect2", + "role": "roi", + "row": 2250.000033590164, + "col": 2445.999963957318, + "phi_deg": -89.99999458063853, + "length1": 159.9999999999998, + "length2": 20.0, + "tool_id": "lato_dx" + }, + { + "type": "segment", + "role": "found", + "row1": 2410.000338325413, + "col1": 2446.9546542059475, + "row2": 2090.0001187672883, + "col2": 2444.590522414638, + "tool_id": "lato_dx" + }, + { + "type": "rect2", + "role": "roi", + "row": 1970.0000406368001, + "col": 2371.4999374733225, + "phi_deg": 5.419361327409989e-06, + "length1": 36.499999999999936, + "length2": 15.0, + "tool_id": "lato_alto" + }, + { + "type": "segment", + "role": "found", + "row1": 1969.7270815449738, + "col1": 2335.0012415021283, + "row2": 1969.9815342357629, + "col2": 2408.0075386065682, + "tool_id": "lato_alto" + }, + { + "type": "rect2", + "role": "roi", + "row": 2466.000040636798, + "col": 2371.4999843878295, + "phi_deg": 5.419361327409989e-06, + "length1": 36.499999999999936, + "length2": 15.0, + "tool_id": "lato_basso" + }, + { + "type": "segment", + "role": "found", + "row1": 2466.9468485672455, + "col1": 2335.0088484836465, + "row2": 2465.293164536675, + "col2": 2407.9979613716796, + "tool_id": "lato_basso" + }, + { + "type": "arc", + "role": "roi", + "row": 2015.6000435878736, + "col": 2340.2999417864303, + "radius": 46.0, + "start_deg": 95.00000541936147, + "extent_deg": 80.0, + "tool_id": "arco_tl" + }, + { + "type": "arc", + "role": "found", + "row": 2014.0729365101115, + "col": 2338.325339664551, + "radius": 43.69800114451398, + "start_deg": 92.61763130401864, + "extent_deg": 84.15103825789168, + "tool_id": "arco_tl" + }, + { + "type": "arc", + "role": "roi", + "row": 2016.1000381586543, + "col": 2397.699941833723, + "radius": 46.0, + "start_deg": 5.000005419361472, + "extent_deg": 80.0, + "tool_id": "arco_tr" + }, + { + "type": "arc", + "role": "found", + "row": 2014.7155277666257, + "col": 2400.767078642102, + "radius": 43.26606749223183, + "start_deg": 3.5348692687035217, + "extent_deg": 85.33536916618716, + "tool_id": "arco_tr" + }, + { + "type": "arc", + "role": "roi", + "row": 2418.4000378086853, + "col": 2401.3999798855507, + "radius": 46.0, + "start_deg": 275.00000541936146, + "extent_deg": 80.0, + "tool_id": "arco_br" + }, + { + "type": "arc", + "role": "found", + "row": 2420.300713032017, + "col": 2404.4253040181898, + "radius": 42.604285715934104, + "start_deg": -76.0934883340429, + "extent_deg": 73.28101449433917, + "tool_id": "arco_br" + }, + { + "type": "arc", + "role": "roi", + "row": 2421.8000432662807, + "col": 2343.699980207142, + "radius": 46.0, + "start_deg": 185.00000541936146, + "extent_deg": 80.0, + "tool_id": "arco_bl" + }, + { + "type": "arc", + "role": "found", + "row": 2423.3935764712314, + "col": 2338.8006257209418, + "radius": 41.362918221221754, + "start_deg": -176.60049573516702, + "extent_deg": 77.4552373110766, + "tool_id": "arco_bl" + }, + { + "type": "segment", + "role": "found", + "row1": 2250.0002285463506, + "col1": 2445.772588310293, + "row2": 2251.0268387327387, + "col2": 2296.253250501527, + "tool_id": "larghezza" + }, + { + "type": "segment", + "role": "found", + "row1": 2466.12000655196, + "col1": 2371.503404927663, + "row2": 1969.8603328620893, + "col2": 2373.2330448944704, + "tool_id": "lunghezza" + }, + { + "type": "circle", + "role": "found", + "row": 2014.0729365101115, + "col": 2338.325339664551, + "radius": 43.69800114451398, + "tool_id": "R_tl" + }, + { + "type": "circle", + "role": "found", + "row": 2014.7155277666257, + "col": 2400.767078642102, + "radius": 43.26606749223183, + "tool_id": "R_tr" + }, + { + "type": "circle", + "role": "found", + "row": 2420.300713032017, + "col": 2404.4253040181898, + "radius": 42.604285715934104, + "tool_id": "R_br" + }, + { + "type": "circle", + "role": "found", + "row": 2423.3935764712314, + "col": 2338.8006257209418, + "radius": 41.362918221221754, + "tool_id": "R_bl" + } + ], + "failures": [], + "measurements": [ + { + "subtask_id": 15, + "value": 1.6682265730716714, + "pass_fail": "pass" + }, + { + "subtask_id": 16, + "value": 5.536802808714362, + "pass_fail": "pass" + }, + { + "subtask_id": 17, + "value": 0.48753859876934247, + "pass_fail": "pass" + }, + { + "subtask_id": 18, + "value": 0.4827195150108305, + "pass_fail": "pass" + }, + { + "subtask_id": 19, + "value": 0.4753360157326768, + "pass_fail": "pass" + }, + { + "subtask_id": 20, + "value": 0.4614860785941711, + "pass_fail": "pass" + }, + { + "subtask_id": 21, + "value": 0.004819083758511997, + "pass_fail": "pass" + }, + { + "subtask_id": 22, + "value": 0.012202583036665687, + "pass_fail": "pass" + }, + { + "subtask_id": 23, + "value": 0.026052520175171373, + "pass_fail": "pass" + }, + { + "subtask_id": 24, + "value": 0.00738349927815369, + "pass_fail": "pass" + }, + { + "subtask_id": 25, + "value": 0.021233436416659375, + "pass_fail": "pass" + }, + { + "subtask_id": 26, + "value": 0.013849937138505686, + "pass_fail": "pass" + } + ], + "calibration": "ok", + "_subtasks": [ + { + "id": 15, + "marker_number": 1, + "description": "Larghezza", + "measurement_type": null, + "unit": "mm", + "vision_output": "larghezza.value", + "nominal": 1.668, + "ltl": null, + "utl": null + }, + { + "id": 16, + "marker_number": 2, + "description": "Lunghezza", + "measurement_type": null, + "unit": "mm", + "vision_output": "lunghezza.value", + "nominal": 5.537, + "ltl": null, + "utl": null + }, + { + "id": 17, + "marker_number": 3, + "description": "R alto-sinistra", + "measurement_type": null, + "unit": "mm", + "vision_output": "R_tl.value", + "nominal": 0.488, + "ltl": null, + "utl": null + }, + { + "id": 18, + "marker_number": 4, + "description": "R alto-destra", + "measurement_type": null, + "unit": "mm", + "vision_output": "R_tr.value", + "nominal": 0.483, + "ltl": null, + "utl": null + }, + { + "id": 19, + "marker_number": 5, + "description": "R basso-destra", + "measurement_type": null, + "unit": "mm", + "vision_output": "R_br.value", + "nominal": 0.475, + "ltl": null, + "utl": null + }, + { + "id": 20, + "marker_number": 6, + "description": "R basso-sinistra", + "measurement_type": null, + "unit": "mm", + "vision_output": "R_bl.value", + "nominal": 0.464, + "ltl": null, + "utl": null + }, + { + "id": 21, + "marker_number": 7, + "description": "Diff. R alto-sx / alto-dx", + "measurement_type": null, + "unit": "mm", + "vision_output": "d_tl_tr.value", + "nominal": 0.0, + "ltl": -0.05, + "utl": 0.05 + }, + { + "id": 22, + "marker_number": 8, + "description": "Diff. R alto-sx / basso-dx", + "measurement_type": null, + "unit": "mm", + "vision_output": "d_tl_br.value", + "nominal": 0.0, + "ltl": -0.05, + "utl": 0.05 + }, + { + "id": 23, + "marker_number": 9, + "description": "Diff. R alto-sx / basso-sx", + "measurement_type": null, + "unit": "mm", + "vision_output": "d_tl_bl.value", + "nominal": 0.0, + "ltl": -0.05, + "utl": 0.05 + }, + { + "id": 24, + "marker_number": 10, + "description": "Diff. R alto-dx / basso-dx", + "measurement_type": null, + "unit": "mm", + "vision_output": "d_tr_br.value", + "nominal": 0.0, + "ltl": -0.05, + "utl": 0.05 + }, + { + "id": 25, + "marker_number": 11, + "description": "Diff. R alto-dx / basso-sx", + "measurement_type": null, + "unit": "mm", + "vision_output": "d_tr_bl.value", + "nominal": 0.0, + "ltl": -0.05, + "utl": 0.05 + }, + { + "id": 26, + "marker_number": 12, + "description": "Diff. R basso-dx / basso-sx", + "measurement_type": null, + "unit": "mm", + "vision_output": "d_br_bl.value", + "nominal": 0.0, + "ltl": -0.05, + "utl": 0.05 + } + ] +} \ No newline at end of file diff --git a/src/frontend/flask_app/tests/js/test_vision_overlay.js b/src/frontend/flask_app/tests/js/test_vision_overlay.js new file mode 100644 index 0000000..e514989 --- /dev/null +++ b/src/frontend/flask_app/tests/js/test_vision_overlay.js @@ -0,0 +1,228 @@ +/** + * #116: overlay del check visione (static/js/vision_overlay.js) con plain node. + * + * Run from src/frontend/flask_app: node tests/js/test_vision_overlay.js + */ +'use strict'; +const fs = require('fs'); +const path = require('path'); + +const VisionOverlay = require(path.join(__dirname, '..', '..', 'static', 'js', 'vision_overlay.js')); +const fixture = JSON.parse( + fs.readFileSync(path.join(__dirname, 'fixtures', 'vision_check_task14.json'), 'utf8'), +); + +let failures = 0; +let total = 0; +function check(label, cond, extra) { + total++; + if (cond) console.log('ok - ' + label); + else { failures++; console.log('FAIL - ' + label + (extra ? ' :: ' + extra : '')); } +} +const near = (a, b) => Math.abs(a - b) < 1e-9; + +// Canvas finto: registra le chiamate e le proprieta' al momento dello stroke +function fakeCanvas() { + const calls = []; + const g = { + calls, + strokes: [], + dash: [], + strokeStyle: '', + lineWidth: 0, + beginPath() { calls.push(['beginPath']); }, + moveTo(x, y) { calls.push(['moveTo', x, y]); }, + lineTo(x, y) { calls.push(['lineTo', x, y]); }, + arc(...a) { calls.push(['arc', ...a]); }, + closePath() { calls.push(['closePath']); }, + stroke() { + calls.push(['stroke']); + g.strokes.push({ style: g.strokeStyle, width: g.lineWidth, dash: g.dash.slice() }); + }, + setLineDash(d) { g.dash = d.slice(); calls.push(['setLineDash', d.slice()]); }, + }; + return g; +} +const onlyCalls = (g, name) => g.calls.filter((c) => c[0] === name); + +// --- fixture vero: task 14 TEST1 --- +const overlay = fixture.overlay; +check('fixture: overlay con 25 elementi', overlay.length === 25, String(overlay.length)); +{ + const g = fakeCanvas(); + const res = VisionOverlay.draw(g, overlay, 2); + check('draw sul fixture: drawn 25, skipped 0', res.drawn === 25 && res.skipped === 0, JSON.stringify(res)); + const lastDash = onlyCalls(g, 'setLineDash').pop(); + check('draw: ultimo setLineDash e\' []', lastDash && lastDash[1].length === 0); + check('draw: uno stroke per elemento', g.strokes.length === 25, String(g.strokes.length)); + const roiIdx = overlay.findIndex((o) => o.role === 'roi'); + const foundIdx = overlay.findIndex((o) => o.role === 'found'); + check('roi: arancione, tratteggiata, spessore lw*0.75', + g.strokes[roiIdx].style === '#f59e0b' && g.strokes[roiIdx].width === 1.5 && + JSON.stringify(g.strokes[roiIdx].dash) === '[6,4]', JSON.stringify(g.strokes[roiIdx])); + check('found: verde, piena, spessore lw', + g.strokes[foundIdx].style === '#16a34a' && g.strokes[foundIdx].width === 2 && + g.strokes[foundIdx].dash.length === 0, JSON.stringify(g.strokes[foundIdx])); +} + +// --- colori: reference e ruolo ignoto --- +{ + const g = fakeCanvas(); + VisionOverlay.draw(g, [ + { type: 'segment', role: 'reference', row1: 0, col1: 0, row2: 1, col2: 1 }, + { type: 'segment', role: 'boh', row1: 0, col1: 0, row2: 1, col2: 1 }, + ], 1); + check('reference blu', g.strokes[0].style === '#2563eb'); + check('ruolo ignoto rosso', g.strokes[1].style === '#ef4444'); +} + +// --- rect2 --- +function xs(g) { return onlyCalls(g, 'moveTo').concat(onlyCalls(g, 'lineTo')).map((c) => c[1]); } +function ys(g) { return onlyCalls(g, 'moveTo').concat(onlyCalls(g, 'lineTo')).map((c) => c[2]); } +{ + const g = fakeCanvas(); + const r = VisionOverlay.draw(g, [{ type: 'rect2', role: 'roi', row: 50, col: 100, phi_deg: 0, length1: 10, length2: 4 }], 1); + const X = xs(g), Y = ys(g); + check('rect2 phi 0: 4 vertici chiusi', r.drawn === 1 && X.length === 4 && onlyCalls(g, 'closePath').length === 1); + check('rect2 phi 0: col 90/110', X.every((x) => near(x, 90) || near(x, 110)) && X.filter((x) => near(x, 90)).length === 2, String(X)); + check('rect2 phi 0: row 46/54', Y.every((y) => near(y, 46) || near(y, 54)) && Y.filter((y) => near(y, 46)).length === 2, String(Y)); +} +{ + const g = fakeCanvas(); + VisionOverlay.draw(g, [{ type: 'rect2', role: 'roi', row: 50, col: 100, phi_deg: 90, length1: 10, length2: 4 }], 1); + const X = xs(g), Y = ys(g); + check('rect2 phi 90: lati scambiati, col 96/104', X.every((x) => near(x, 96) || near(x, 104)), String(X)); + check('rect2 phi 90: lati scambiati, row 40/60', Y.every((y) => near(y, 40) || near(y, 60)), String(Y)); +} + +// --- arc: argomenti come da contratto --- +{ + const g = fakeCanvas(); + const a = { type: 'arc', role: 'found', row: 30, col: 40, radius: 12, start_deg: 90, extent_deg: 90 }; + VisionOverlay.draw(g, [a], 1); + const c = onlyCalls(g, 'arc')[0]; + check('arc: g.arc(col, row, r, -rad(start), -rad(start+extent), extent>0)', + c && near(c[1], 40) && near(c[2], 30) && near(c[3], 12) && + near(c[4], -Math.PI / 2) && near(c[5], -Math.PI) && c[6] === true, JSON.stringify(c)); + const g2 = fakeCanvas(); + VisionOverlay.draw(g2, [{ ...a, start_deg: 0, extent_deg: -45 }], 1); + const c2 = onlyCalls(g2, 'arc')[0]; + check('arc: extent negativo = orario (anticlockwise false)', + c2 && near(c2[4], 0) && near(c2[5], Math.PI / 4) && c2[6] === false, JSON.stringify(c2)); +} + +// --- circle, points, contour --- +{ + const g = fakeCanvas(); + VisionOverlay.draw(g, [{ type: 'circle', role: 'found', row: 5, col: 7, radius: 3 }], 1); + const c = onlyCalls(g, 'arc')[0]; + check('circle: cerchio pieno', c && c[1] === 7 && c[2] === 5 && c[3] === 3 && c[4] === 0 && near(c[5], 2 * Math.PI)); +} +{ + const g = fakeCanvas(); + VisionOverlay.draw(g, [{ type: 'points', role: 'found', rows: [10], cols: [20] }], 1); + const m = onlyCalls(g, 'moveTo').concat(onlyCalls(g, 'lineTo')); + // lw 1 -> semi-lato max(3, 2) = 3 + check('points: croce di semi-lato 3', m.length === 4 && m.some((c) => c[1] === 17 && c[2] === 10) && + m.some((c) => c[1] === 23 && c[2] === 10) && m.some((c) => c[1] === 20 && c[2] === 7) && + m.some((c) => c[1] === 20 && c[2] === 13), JSON.stringify(m)); + const g2 = fakeCanvas(); + VisionOverlay.draw(g2, [{ type: 'points', role: 'found', rows: [10], cols: [20] }], 5); + check('points: semi-lato lw*2 per lw grande', onlyCalls(g2, 'moveTo').some((c) => c[1] === 10 && c[2] === 10)); +} +{ + const g = fakeCanvas(); + VisionOverlay.draw(g, [{ type: 'contour', role: 'found', rows: [1, 2, 3], cols: [4, 5, 6] }], 1); + check('contour: polilinea aperta di 3 punti', + onlyCalls(g, 'moveTo').length === 1 && onlyCalls(g, 'lineTo').length === 2 && onlyCalls(g, 'closePath').length === 0); +} + +// --- elementi malformati: saltati, nessuna eccezione --- +{ + const g = fakeCanvas(); + let thrown = null; + let res = null; + try { + res = VisionOverlay.draw(g, [ + { type: 'circle', role: 'found', row: 1, col: 1 }, // manca radius + { type: 'points', role: 'found', rows: [1, 2], cols: [1] }, // lunghezze diverse + { type: 'contour', role: 'found', rows: [], cols: [] }, // vuoto + { type: 'boh', role: 'found' }, // tipo ignoto + { type: 'segment', role: 'found', row1: NaN, col1: 0, row2: 1, col2: 1 }, // non finito + null, // elemento nullo + { type: 'segment', role: 'found', row1: 0, col1: 0, row2: 1, col2: 1 }, // valido + ], 1); + } catch (e) { thrown = e; } + check('malformati: nessuna eccezione', thrown === null, String(thrown)); + check('malformati: 6 saltati, 1 disegnato', res && res.drawn === 1 && res.skipped === 6, JSON.stringify(res)); + check('malformati: draw con overlay non array', (() => { + try { const r = VisionOverlay.draw(fakeCanvas(), null, 1); return r.drawn === 0 && r.skipped === 0; } catch (e) { return false; } + })()); +} + +// --- anchor, valori calcolati a mano --- +{ + const A = VisionOverlay.anchor; + const seg = A({ type: 'segment', row1: 10, col1: 0, row2: 20, col2: 40 }); + check('anchor segment: punto medio', seg && seg.x === 20 && seg.y === 15); + const lin = A({ type: 'line', row1: 0, col1: 2, row2: 0, col2: 4 }); + check('anchor line: punto medio', lin && lin.x === 3 && lin.y === 0); + const cir = A({ type: 'circle', row: 50, col: 100, radius: 8 }); + check('anchor circle: centro + raggio a destra', cir && cir.x === 108 && cir.y === 50); + const arc = A({ type: 'arc', row: 50, col: 100, radius: 10, start_deg: 0, extent_deg: 180 }); + check('anchor arc: punto medio (90 gradi = in alto a video)', arc && near(arc.x, 100) && near(arc.y, 40), JSON.stringify(arc)); + const pts = A({ type: 'points', rows: [7, 8], cols: [3, 4] }); + check('anchor points: primo punto', pts && pts.x === 3 && pts.y === 7); + const r2 = A({ type: 'rect2', row: 9, col: 11, phi_deg: 30, length1: 5, length2: 2 }); + check('anchor rect2: centro', r2 && r2.x === 11 && r2.y === 9); + const con = A({ type: 'contour', rows: [0, 1, 2, 3], cols: [10, 11, 12, 13] }); + check('anchor contour: punto a meta\' (indice 2)', con && con.x === 12 && con.y === 2); + check('anchor: tipo ignoto -> null', A({ type: 'boh' }) === null); + check('anchor: dati mancanti -> null', A({ type: 'circle', row: 1, col: 1 }) === null && A(null) === null); +} + +// --- labels sul fixture, righe costruite come fara' vision_execute.js --- +const subById = {}; +fixture._subtasks.forEach((s) => { subById[s.id] = s; }); +const rows = fixture.measurements.map((m) => { + const s = subById[m.subtask_id]; + return { + key: m.subtask_id, name: s.description, value: m.value, unit: s.unit, + status: m.pass_fail, vision_output: s.vision_output, + }; +}); +{ + const L = VisionOverlay.labels(rows, overlay); + check('labels fixture: 6 etichette', L.length === 6, String(L.length)); + const names = L.map((l) => l.text.split(' ')[0]).join(','); + check('labels fixture: larghezza, lunghezza e 4 raggi', + L[0].text.startsWith('Larghezza') && L[1].text.startsWith('Lunghezza') && + L.slice(2).every((l) => l.text.startsWith('R ')), names); + check('labels fixture: nessuna per le differenze', L.every((l) => !/^Diff/.test(l.text))); + const l15 = L.find((l) => l.key === 15); + check('quota 15: testo con Larghezza e 1.668', l15 && l15.text.includes('Larghezza') && l15.text.includes('1.668'), l15 && l15.text); + check('quota 15: status riportato', l15 && l15.status === fixture.measurements[0].pass_fail); + const seg = overlay.find((o) => o.tool_id === 'larghezza' && o.role === 'found'); + check('quota 15: ancorata al punto medio del segmento', l15 && near(l15.x, (seg.col1 + seg.col2) / 2) && near(l15.y, (seg.row1 + seg.row2) / 2)); +} +{ + const nuda = rows.map((r) => ({ ...r, vision_output: undefined })); + check('labels: righe senza vision_output -> nessuna etichetta', VisionOverlay.labels(nuda, overlay).length === 0); + const sola = VisionOverlay.labels([{ key: 1, name: 'X', value: 2, unit: 'mm', status: 'pass' }], overlay); + check('labels: riga senza vision_output (assente) -> nessuna', sola.length === 0); + const noTool = VisionOverlay.labels([{ key: 1, name: 'X', value: 2, unit: 'mm', status: 'pass', vision_output: 'nonesiste.value' }], overlay); + check('labels: tool senza overlay -> nessuna', noTool.length === 0); + // found preferito: la roi (per prima) ha un'altra ancora, ma vince il found + const ov = [ + { type: 'rect2', role: 'roi', tool_id: 't', row: 1, col: 1, phi_deg: 0, length1: 1, length2: 1 }, + { type: 'segment', role: 'found', tool_id: 't', row1: 10, col1: 10, row2: 10, col2: 20 }, + ]; + const f = VisionOverlay.labels([{ key: 1, name: 'T', value: 1.5, unit: 'mm', status: 'warning', vision_output: 't.value' }], ov); + check('labels: preferisce l\'ancora del found', f.length === 1 && f[0].x === 15 && f[0].y === 10 && f[0].status === 'warning', JSON.stringify(f)); + const solo = VisionOverlay.labels([{ key: 1, name: 'T', value: 1.5, unit: 'mm', status: 'fail', vision_output: 't.value' }], [ov[0]]); + check('labels: senza found ripiega sul primo elemento', solo.length === 1 && solo[0].x === 1 && solo[0].y === 1); + check('labels: testo name + valore a 3 decimali + unit', f[0].text === 'T 1.500 mm', f[0].text); +} + +console.log('\n' + (total - failures) + '/' + total + ' controlli ok'); +process.exit(failures ? 1 : 0); diff --git a/src/frontend/flask_app/tests/test_vision_overlay_js.py b/src/frontend/flask_app/tests/test_vision_overlay_js.py new file mode 100644 index 0000000..02c9e01 --- /dev/null +++ b/src/frontend/flask_app/tests/test_vision_overlay_js.py @@ -0,0 +1,35 @@ +"""Runs the plain-node vision overlay draw/anchor/labels test (tests/js/test_vision_overlay.js). + +Same thin pytest wrapper pattern as test_vision_rows_js.py: shells out to +``node`` and asserts the script's own exit code. Skips (does not fail) when +node is not installed. + +Covers gitea:Adriano/TieMeasureFlow#116. +""" +import shutil +import subprocess +from pathlib import Path + +import pytest + +FLASK_APP_DIR = Path(__file__).resolve().parent.parent +JS_TEST = Path(__file__).resolve().parent / "js" / "test_vision_overlay.js" + + +def test_vision_overlay_js_behaviour(): + node = shutil.which("node") + if not node: + pytest.skip("node non installato: test JS dell'overlay saltato") + + result = subprocess.run( + [node, str(JS_TEST)], + cwd=str(FLASK_APP_DIR), + capture_output=True, + text=True, + timeout=30, + ) + + assert result.returncode == 0, ( + f"tests/js/test_vision_overlay.js fallito (exit {result.returncode})\n" + f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + )