From 3856ee335a02b779e65da2f5df0d505f6c06c0d0 Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Wed, 7 Oct 2026 11:55:42 +0200 Subject: [PATCH] operatore: acquisizione immagine, proxy execute e overlay per camera_measure/dxf_compare (#46) Co-Authored-By: Claude Sonnet 5.5 --- src/frontend/flask_app/blueprints/measure.py | 68 +++++++++- .../flask_app/static/js/vision_execute.js | 125 ++++++++++++++++++ .../templates/measure/task_execute.html | 78 ++++++++++- .../flask_app/tests/test_measure_vision.py | 86 ++++++++++++ 4 files changed, 355 insertions(+), 2 deletions(-) create mode 100644 src/frontend/flask_app/static/js/vision_execute.js create mode 100644 src/frontend/flask_app/tests/test_measure_vision.py diff --git a/src/frontend/flask_app/blueprints/measure.py b/src/frontend/flask_app/blueprints/measure.py index fd2ed4b..6c61b01 100644 --- a/src/frontend/flask_app/blueprints/measure.py +++ b/src/frontend/flask_app/blueprints/measure.py @@ -1,8 +1,10 @@ """MeasurementTec blueprint - recipe selection and measurement execution.""" import urllib.parse +import requests as http_requests + from flask import ( - Blueprint, abort, flash, jsonify, redirect, render_template, + Blueprint, Response, abort, flash, jsonify, redirect, render_template, request, session, url_for, ) from flask_babel import gettext as _ @@ -820,3 +822,67 @@ def api_close_production(run_id: int): def api_get_file(file_path: str): """Proxy: Serve file from API server (browser can't send X-API-Key).""" return proxy_file(file_path) + + +# --------------------------------------------------------------------------- +# Route: vision execute (#46) - the browser sends an image, we relay it to +# POST /api/vision/execute (multipart) because it cannot send X-API-Key itself. +# --------------------------------------------------------------------------- +@measure_bp.route("/api/vision/execute", methods=["POST"]) +@login_required +@role_required("MeasurementTec") +def api_vision_execute(): + """Proxy: run the task's VisionSuite recipe on the uploaded image.""" + image = request.files.get("image") + task_id = request.form.get("task_id") + if image is None or not task_id: + return jsonify({ + "error": True, + "detail": _("Dati mancanti: task_id e image sono obbligatori"), + }), 400 + + # Same context fields as the manual flow; empty ones are left out so the + # backend sees them as absent rather than as "". + form = {"task_id": task_id} + for key in ("task_type", "lot_number", "serial_number", "production_run_id"): + val = request.form.get(key) + if val: + form[key] = val + form.setdefault("lot_number", session.get("lot_number", "")) + form.setdefault("serial_number", session.get("serial_number", "")) + + resp = api_client.post( + "/api/vision/execute", + data=form, + files={"image": ( + image.filename or "capture.jpg", + image.read(), + image.mimetype or "application/octet-stream", + )}, + ) + if resp.get("error"): + status_code = resp.get("status_code", 500) + body = {"error": True, "detail": resp.get("detail", _("Errore nell'esecuzione"))} + if resp.get("session_expired"): + body["session_expired"] = True + return jsonify(body), status_code if status_code >= 400 else 500 + return jsonify(resp), 200 + + +@measure_bp.route("/api/vision/results//image", methods=["GET"]) +@login_required +@role_required("MeasurementTec") +def api_vision_result_image(result_id: int): + """Proxy: the image saved with a vision result (needs X-API-Key).""" + base_url = Config.API_SERVER_URL.rstrip("/") + resp = http_requests.get( + f"{base_url}/api/vision/results/{result_id}/image", + headers={"X-API-Key": session.get("api_key", "")}, + timeout=30, + ) + if resp.status_code != 200: + return Response(resp.text, status=resp.status_code) + return Response( + resp.content, + content_type=resp.headers.get("content-type", "application/octet-stream"), + ) diff --git a/src/frontend/flask_app/static/js/vision_execute.js b/src/frontend/flask_app/static/js/vision_execute.js new file mode 100644 index 0000000..981c567 --- /dev/null +++ b/src/frontend/flask_app/static/js/vision_execute.js @@ -0,0 +1,125 @@ +/** + * Vision execute - Alpine component for camera_measure / dxf_compare tasks (#46). + * + * The operator takes (or picks) an image, `acquire(blob)` sends it to the Flask + * proxy, and the result - quote table, outcome, failures, overlay - is shown on + * the image. `acquire(blob)` is the single entry point on purpose: the agent + * camera (#3) will call it with its own blob and reuse everything below. + * + * Config comes from window.__visionCfg (set by the template): + * executeUrl, imageUrlTpl (with a 0 placeholder for the result id), taskId, + * taskType, subtasks, labels. + * Run context (lot, serial, production run) is read lazily through `ctx()`, + * which the template binds to the parent task component. + */ +function visionExecute() { + // Overlay colour per role (VisionSuite roles: reference, roi, found). + const ROLE_COLORS = { reference: '#2563eb', found: '#16a34a', roi: '#f59e0b' }; + + return { + busy: false, + error: '', + result: null, // last execute response + imageSrc: '', // object URL of the acquired image (shown under the overlay) + ctx: () => ({}), // replaced by the template: {lot, serial, runId} + + // Entry point shared with the future agent camera. + async acquire(blob) { + if (!blob || this.busy) return; + const cfg = window.__visionCfg || {}; + this.busy = true; + this.error = ''; + this.result = null; + // Show the picked image at once: the overlay is drawn on top when the result arrives. + if (this.imageSrc) URL.revokeObjectURL(this.imageSrc); + this.imageSrc = URL.createObjectURL(blob); + try { + const c = this.ctx() || {}; + const fd = new FormData(); + fd.append('task_id', cfg.taskId); + fd.append('task_type', cfg.taskType || ''); + fd.append('image', blob, blob.name || 'capture.jpg'); + fd.append('lot_number', c.lot || ''); + fd.append('serial_number', c.serial || ''); + if (c.runId) fd.append('production_run_id', c.runId); + const csrf = document.querySelector('meta[name=csrf-token]')?.content || ''; + const resp = await fetch(cfg.executeUrl, { + method: 'POST', headers: { 'X-CSRFToken': csrf }, body: fd, + }); + const body = await resp.json().catch(() => ({})); + if (!resp.ok || body.error) { + this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error'; + return; + } + this.result = body; + // Draw once Alpine has rendered the canvas. + this.$nextTick(() => this.drawOverlay()); + } catch (e) { + this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error'; + } finally { + this.busy = false; + } + }, + + // Input change: take the file and allow picking the same one again. + onPick(ev) { + const f = ev.target.files && ev.target.files[0]; + ev.target.value = ''; + if (f) this.acquire(f); + }, + + // Image shown: the saved one from the server when available, else the local one. + get shownImage() { + const cfg = window.__visionCfg || {}; + if (this.result && this.result.vision_result_id && cfg.imageUrlTpl) { + return cfg.imageUrlTpl.replace('/0/', '/' + this.result.vision_result_id + '/'); + } + return this.imageSrc; + }, + + // Quote rows: server measurements joined with the subtask names. + get rows() { + const cfg = window.__visionCfg || {}; + const subs = cfg.subtasks || []; + return ((this.result && this.result.measurements) || []).map((m) => { + const st = subs.find((s) => s.id === m.subtask_id) || {}; + return { + label: (st.marker_number ? st.marker_number + '. ' : '') + (st.measurement_type || st.name || '#' + m.subtask_id), + value: m.value, + unit: st.unit || 'mm', + status: m.pass_fail, + }; + }); + }, + + statusClass(s) { + // Same palette as the measurement markers of the manual flow. + if (s === 'pass') return 'bg-measure-pass/20 text-measure-pass border border-measure-pass/40'; + if (s === 'fail') return 'bg-red-100 dark:bg-red-900/30 text-measure-fail border border-measure-fail/40'; + if (s === 'warning') return 'bg-amber-100 dark:bg-amber-900/30 text-measure-warning border border-measure-warning/40'; + return 'bg-[var(--bg-secondary)] text-[var(--text-muted)] border border-[var(--border-color)]'; + }, + + // Canvas is sized to the natural image, so overlay coordinates (image px) + // are used as they are and CSS scales the whole thing to the displayed size. + drawOverlay() { + const img = this.$refs.img, cv = this.$refs.overlay; + if (!img || !cv || !img.naturalWidth) return; + cv.width = img.naturalWidth; + cv.height = img.naturalHeight; + const g = cv.getContext('2d'); + g.clearRect(0, 0, cv.width, cv.height); + g.lineWidth = Math.max(2, cv.width / 400); + g.lineJoin = 'round'; + for (const item of ((this.result && this.result.overlay) || [])) { + const rows = item.rows || [], cols = item.cols || []; + if (!rows.length || rows.length !== cols.length) continue; + g.strokeStyle = ROLE_COLORS[item.role] || '#ef4444'; + g.beginPath(); + g.moveTo(cols[0], rows[0]); + for (let i = 1; i < rows.length; i++) g.lineTo(cols[i], rows[i]); + g.stroke(); + } + }, + }; +} diff --git a/src/frontend/flask_app/templates/measure/task_execute.html b/src/frontend/flask_app/templates/measure/task_execute.html index edb6f80..cd925b6 100644 --- a/src/frontend/flask_app/templates/measure/task_execute.html +++ b/src/frontend/flask_app/templates/measure/task_execute.html @@ -191,7 +191,8 @@ {# ================================================================ MAIN — 3-column layout ================================================================ #} -
+ {# #46: camera_measure / dxf_compare get the acquisition panel below instead of the manual layout #} +
{# ────────────────────────────────────────────── LEFT SIDEBAR — Marker list (vertical, hidden when no subtasks) @@ -451,6 +452,63 @@
+ {# ================================================================ + VISION PANEL (#46) - acquire an image, run the recipe server-side, show the + quotes and the overlay. acquire(blob) is the entry point the agent camera + (#3) will reuse. + ================================================================ #} +
+ +
+
+ {# On a tablet this opens the camera; on a PC it picks a file. «Riprova» reuses the same input. #} + + {{ _('Elaborazione in corso...') }} +
+ +
+ +
+ + +
+
+ {{ _('Nessuna immagine acquisita: premi «Acquisisci immagine».') }} +
+
+ +
+
+ {{ _('Esito') }}: +
+ + + +
+ +
+
{{ _('Strumenti falliti') }}
+ +
+
+
+ {# ================================================================ MEASUREMENT TIMER BANNER ================================================================ #} @@ -912,6 +970,18 @@ +{# #46: acquisition + overlay for camera_measure / dxf_compare #} + + {# The clock's rules - how a delay reads, when the buzzer goes, where the measurement lives - are shared with the other screens rather than restated. #} @@ -1776,6 +1846,12 @@ function taskExecute() { return declared === 'measure' || declared === 'camera_measure'; }, + /* #46: image-based tasks run a VisionSuite recipe on the server. */ + get isVisionTask() { + const t = this.task.task_type; + return t === 'camera_measure' || t === 'dxf_compare'; + }, + /* A measurement task with no quotes yet: the recipe is unfinished. Said out loud rather than silently rendering an empty screen. */ get isMeasureTaskWithoutQuotes() { diff --git a/src/frontend/flask_app/tests/test_measure_vision.py b/src/frontend/flask_app/tests/test_measure_vision.py new file mode 100644 index 0000000..b660d31 --- /dev/null +++ b/src/frontend/flask_app/tests/test_measure_vision.py @@ -0,0 +1,86 @@ +"""gitea:Adriano/TieMeasureFlow#46 - operator side of the vision tasks. + +The backend of contract B is not merged yet: api_client is mocked. +""" +import io +from unittest.mock import MagicMock, patch + +from tests.conftest import api_get_router + +TASK = { + "id": 12, "recipe_id": 3, "version_id": 5, "task_type": "camera_measure", + "title": "Foto", "directive": None, "description": None, "file_path": None, + "file_type": None, "annotations_json": None, "subtasks": [], +} +RECIPE = { + "id": 3, "code": "REC-1", "name": "Ricetta", + "measurement_interval_minutes": 30, "allow_manual_input": True, + "current_version": {"id": 5, "version_number": 1}, +} + + +def test_execute_page_has_acquire_input(logged_in_client, mock_api_client, monkeypatch): + """camera_measure screen offers the file/camera input and the acquire entry point.""" + monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST") + mock_api_client.get_station_recipes.return_value = {"items": [{"id": 3}]} + mock_api_client.get.side_effect = api_get_router({ + "/api/tasks/12": TASK, + "/api/recipes/3/tasks": [{"id": 12, "order_index": 0}], + "/api/recipes/3": RECIPE, + }) + html = logged_in_client.get("/measure/execute/12").get_data(as_text=True) + assert 'capture="environment"' in html + assert 'accept="image/*"' in html + assert "visionExecute()" in html + assert "/measure/api/vision/execute" in html + + +def test_vision_execute_proxy_forwards_multipart(logged_in_client, mock_api_client): + mock_api_client.post.return_value = { + "vision_result_id": 7, "outcome": "ok", "overlay": [], "failures": [], + "measurements": [{"subtask_id": 1, "value": 9.9, "pass_fail": "pass"}], + } + resp = logged_in_client.post( + "/measure/api/vision/execute", + data={ + "task_id": "12", "lot_number": "L1", "serial_number": "S1", + "production_run_id": "4", + "image": (io.BytesIO(b"\xff\xd8\xff fake"), "shot.jpg", "image/jpeg"), + }, + content_type="multipart/form-data", + ) + assert resp.status_code == 200 + assert resp.get_json()["vision_result_id"] == 7 + args, kwargs = mock_api_client.post.call_args + assert args[0] == "/api/vision/execute" + assert kwargs["data"]["task_id"] == "12" + assert kwargs["data"]["lot_number"] == "L1" + assert kwargs["data"]["production_run_id"] == "4" + name, content, mime = kwargs["files"]["image"] + assert name == "shot.jpg" and mime == "image/jpeg" and content.startswith(b"\xff\xd8") + + +def test_vision_execute_proxy_requires_image(logged_in_client, mock_api_client): + resp = logged_in_client.post("/measure/api/vision/execute", data={"task_id": "12"}) + assert resp.status_code == 400 + mock_api_client.post.assert_not_called() + + +def test_vision_execute_proxy_relays_backend_error(logged_in_client, mock_api_client): + mock_api_client.post.return_value = {"error": True, "status_code": 422, "detail": "bad"} + resp = logged_in_client.post( + "/measure/api/vision/execute", + data={"task_id": "12", "image": (io.BytesIO(b"x"), "a.jpg", "image/jpeg")}, + content_type="multipart/form-data", + ) + assert resp.status_code == 422 + assert resp.get_json()["detail"] == "bad" + + +def test_vision_result_image_proxy(logged_in_client): + fake = MagicMock(status_code=200, content=b"IMG", headers={"content-type": "image/jpeg"}) + with patch("blueprints.measure.http_requests.get", return_value=fake) as g: + resp = logged_in_client.get("/measure/api/vision/results/7/image") + assert resp.status_code == 200 and resp.data == b"IMG" + assert g.call_args[0][0].endswith("/api/vision/results/7/image") + assert g.call_args[1]["headers"]["X-API-Key"] == "test-api-key-12345"