From 67337fa232fa6ef6fe30f76b1819999b162fbc27 Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Sat, 24 Oct 2026 18:30:31 +0200 Subject: [PATCH] operatore: immagine gia' analizzata all'ingresso, analisi impulsiva o in linea, Registra (Refs #97) Co-Authored-By: Claude Sonnet 5.5 --- src/frontend/flask_app/blueprints/measure.py | 84 +++++++- .../flask_app/static/js/vision_execute.js | 159 ++++++++++++++- .../templates/measure/task_execute.html | 24 ++- .../tests/test_measure_vision_check.py | 182 ++++++++++++++++++ 4 files changed, 436 insertions(+), 13 deletions(-) create mode 100644 src/frontend/flask_app/tests/test_measure_vision_check.py diff --git a/src/frontend/flask_app/blueprints/measure.py b/src/frontend/flask_app/blueprints/measure.py index d5acbd7..c634b53 100644 --- a/src/frontend/flask_app/blueprints/measure.py +++ b/src/frontend/flask_app/blueprints/measure.py @@ -1,4 +1,5 @@ """MeasurementTec blueprint - recipe selection and measurement execution.""" +import json import urllib.parse import requests as http_requests @@ -337,6 +338,14 @@ def task_execute(task_id: int): measurement_interval_minutes = recipe_resp.get("measurement_interval_minutes") allow_manual_input = bool(recipe_resp.get("allow_manual_input", True)) + # #97: camera del task (acquisition_json) o della stazione; il modo di analisi + # (impulsiva / in_linea) viene dal task. Solo i task visione ne hanno bisogno. + camera = None + camera_mode = "impulsiva" + if task_resp.get("task_type") in ("camera_measure", "dxf_compare"): + camera = _task_camera(task_resp, _current_station()[0]) + camera_mode = _task_mode(task_resp) + return render_template( "measure/task_execute.html", task=task_resp, @@ -345,7 +354,8 @@ def task_execute(task_id: int): all_task_ids=all_task_ids, measurement_interval_minutes=measurement_interval_minutes, allow_manual_input=allow_manual_input, - camera_enabled=_station_camera(_current_station()[0]) is not None, + camera_enabled=camera is not None, + camera_mode=camera_mode, ) @@ -1001,6 +1011,42 @@ def api_vision_execute(): return jsonify(resp), 200 +# #97: analysis of a frame without saving anything (preview before «Registra»). +@measure_bp.route("/api/vision/check", methods=["POST"]) +@login_required +@role_required("MeasurementTec") +def api_vision_check(): + """Proxy: run the task's VisionSuite recipe on the image, nothing is saved.""" + 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 + + form = {"task_id": task_id} + if request.form.get("production_run_id"): + form["production_run_id"] = request.form["production_run_id"] + + resp = api_client.post( + "/api/vision/check", + 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 + + def _station_camera(station_code: str | None) -> tuple[str, str] | None: """(camera_url, camera_source) of the station, None when it has no camera. @@ -1019,6 +1065,31 @@ def _station_camera(station_code: str | None) -> tuple[str, str] | None: return str(url), str(source) +def _task_acquisition(task: dict | None) -> dict | None: + """#97: acquisition_json of a task as a dict, None when absent or unreadable.""" + acq = task.get("acquisition_json") if isinstance(task, dict) else None + if isinstance(acq, str): + try: + acq = json.loads(acq) + except ValueError: + return None + return acq if isinstance(acq, dict) else None + + +def _task_camera(task: dict | None, station_code: str | None) -> tuple[str, str] | None: + """#97: camera of a task = its acquisition_json (url + source), else the station's.""" + acq = _task_acquisition(task) + if acq and acq.get("camera_url") and acq.get("camera_source"): + return str(acq["camera_url"]), str(acq["camera_source"]) + return _station_camera(station_code) + + +def _task_mode(task: dict | None) -> str: + """#97: analysis mode of a task, «impulsiva» unless acquisition_json says «in_linea».""" + acq = _task_acquisition(task) + return "in_linea" if acq and acq.get("modo") == "in_linea" else "impulsiva" + + # #72: the browser asks the station's camera service (vs-gui) for a frame; the # service address is a server-side matter, so Flask relays and the PNG goes back as is. @measure_bp.route("/api/vision/grab", methods=["POST"]) @@ -1026,7 +1097,16 @@ def _station_camera(station_code: str | None) -> tuple[str, str] | None: @role_required("MeasurementTec") def api_vision_grab(): """Proxy: grab one frame from the current station's camera, answer a PNG.""" - camera = _station_camera(_current_station()[0]) + # #97: con task_id (form o json) la camera e' quella del task, altrimenti della stazione + payload = request.get_json(silent=True) or {} + task_id = request.form.get("task_id") or payload.get("task_id") + task = None + if task_id: + try: + task = api_client.get(f"/api/tasks/{int(task_id)}") + except (TypeError, ValueError): + task = None + camera = _task_camera(task, _current_station()[0]) if camera is None: return jsonify({ "error": True, diff --git a/src/frontend/flask_app/static/js/vision_execute.js b/src/frontend/flask_app/static/js/vision_execute.js index 3e22aac..5ac0937 100644 --- a/src/frontend/flask_app/static/js/vision_execute.js +++ b/src/frontend/flask_app/static/js/vision_execute.js @@ -11,6 +11,13 @@ * taskType, subtasks, labels. * Run context (lot, serial, production run) is read lazily through `ctx()`, * which the template binds to the parent task component. + * + * #97: with a camera (cameraAvailable, the task's or the station's) the task opens + * on a frame already grabbed and analysed (/vision/check, nothing saved: «preview»). + * modo «impulsiva»: «Acquisisci» redoes grab+check. modo «in_linea»: continuous + * loop grab+check, one request at a time, standing still while paused / page hidden + * / leaving / saving. «Registra» saves the LAST frame shown through /vision/execute. + * Extra config: cameraAvailable, modo, checkUrl. */ function visionExecute() { // Overlay colour per role (VisionSuite roles: reference, roi, found). @@ -24,6 +31,125 @@ function visionExecute() { imageSrc: '', // object URL of the acquired image (shown under the overlay) ctx: () => ({}), // replaced by the template: {lot, serial, runId} + // #97: preview / loop state + preview: false, // result on screen is an analysis only, not saved + saved: false, // «Registra» done: the saved result replaces the preview + lastBlob: null, // last frame shown, the one «Registra» saves + paused: false, // operator paused the in-line loop + registering: false, // «Registra» in progress: the loop stands still + leaving: false, // page is being left: no more requests + looping: false, // an in-line loop is running + gen: 0, // bumped by «Registra»: answers of older requests are dropped + + // #97: start on a frame already acquired and analysed; hook visibility and exit. + init() { + if (!this.hasCamera) return; + document.addEventListener('visibilitychange', () => { if (!document.hidden) this.kick(); }); + const leave = () => { this.leaving = true; }; + window.addEventListener('pagehide', leave); + window.addEventListener('beforeunload', leave); + // After x-init has bound ctx(): in-line starts the loop, impulsive grabs once. + this.$nextTick(() => { if (this.inLinea) this.loop(); else this.grab(); }); + }, + + get hasCamera() { return !!(window.__visionCfg || {}).cameraAvailable; }, + get inLinea() { return this.hasCamera && (window.__visionCfg || {}).modo === 'in_linea'; }, + // «Registra» needs a frame on screen that is not saved yet + get canRecord() { return !!this.lastBlob && !this.saved && !this.registering; }, + + shouldRun() { + return this.inLinea && !this.paused && !this.saved && !this.registering + && !this.leaving && !document.hidden; + }, + + // Page visible again: restart the loop unless the operator paused it. + kick() { if (this.shouldRun() && !this.looping) this.loop(); }, + + // In-line loop: one request at a time, at least 300 ms between two of them + // (longer after an error, so a dead camera is not hammered). + async loop() { + if (this.looping) return; + this.looping = true; + try { + while (this.shouldRun()) { + const ok = await this.grab(); + await new Promise((r) => setTimeout(r, ok ? 300 : 2000)); + } + } finally { + this.looping = false; + } + }, + + // «Pausa» / «Riprendi»; after «Registra» only «Riprendi» restarts the loop. + togglePause() { + if (this.paused || this.saved) { + this.paused = false; + this.saved = false; + this.loop(); + } else { + this.paused = true; + } + }, + + // Analysis only (/vision/check): the frame becomes the preview, nothing is saved. + async analyze(blob) { + const cfg = window.__visionCfg || {}; + if (!blob || !cfg.checkUrl) return false; + const gen = this.gen; + this.busy = true; + this.error = ''; + try { + const c = this.ctx() || {}; + const fd = new FormData(); + fd.append('task_id', cfg.taskId); + fd.append('image', blob, blob.name || 'capture.jpg'); + if (c.runId) fd.append('production_run_id', c.runId); + const csrf = document.querySelector('meta[name=csrf-token]')?.content || ''; + const resp = await fetch(cfg.checkUrl, { + method: 'POST', headers: { 'X-CSRFToken': csrf }, body: fd, + }); + const body = await resp.json().catch(() => ({})); + if (gen !== this.gen) return false; // «Registra» took over + if (!resp.ok || body.error) { + this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error'; + return false; + } + if (this.imageSrc) URL.revokeObjectURL(this.imageSrc); + this.imageSrc = URL.createObjectURL(blob); + this.lastBlob = blob; + this.result = body; + this.preview = true; + this.saved = false; + this.$nextTick(() => this.drawOverlay()); + return true; + } catch (e) { + if (gen === this.gen) this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error'; + return false; + } finally { + if (gen === this.gen) this.busy = false; + } + }, + + // «Registra»: save the LAST frame shown with the usual /vision/execute. + async record() { + if (!this.canRecord) return; + const blob = this.lastBlob; + this.registering = true; + this.gen++; // drop whatever request is still in flight + this.busy = false; + this.grabbing = false; + try { + await this.acquire(blob); + if (!this.error && this.result && this.result.vision_result_id) { + this.preview = false; + this.saved = true; + } + } finally { + this.registering = false; + this.kick(); // in-line: stays still while saved, restarts if the save failed + } + }, + // Entry point shared with the future agent camera. async acquire(blob) { if (!blob || this.busy) return; @@ -64,37 +190,52 @@ function visionExecute() { // Station camera (#72): the Flask proxy answers a PNG, which goes through // acquire() like a picked file. Errors show as the execute ones do. + // #97: sends task_id (the task's camera wins over the station's); with a camera + // the frame goes to the preview analysis. Returns true when the frame was analysed. async grab() { const cfg = window.__visionCfg || {}; - if (this.busy || this.grabbing || !cfg.grabUrl) return; + if (this.busy || this.grabbing || this.registering || !cfg.grabUrl) return false; + const gen = this.gen; this.grabbing = true; this.error = ''; let blob = null; try { const csrf = document.querySelector('meta[name=csrf-token]')?.content || ''; + const fd = new FormData(); + fd.append('task_id', cfg.taskId); const resp = await fetch(cfg.grabUrl, { - method: 'POST', headers: { 'X-CSRFToken': csrf }, + method: 'POST', headers: { 'X-CSRFToken': csrf }, body: fd, }); if (!resp.ok) { const body = await resp.json().catch(() => ({})); - this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error'; - return; + if (gen === this.gen) this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error'; + return false; } blob = await resp.blob(); } catch (e) { - this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error'; - return; + if (gen === this.gen) this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error'; + return false; } finally { - this.grabbing = false; + if (gen === this.gen) this.grabbing = false; } - if (blob) await this.acquire(blob); + if (!blob || gen !== this.gen) return false; + if (this.hasCamera) return await this.analyze(blob); + await this.acquire(blob); + return !this.error; }, // Input change: take the file and allow picking the same one again. + // #97: with a camera the file is a preview too (the loop pauses), «Registra» saves it. onPick(ev) { const f = ev.target.files && ev.target.files[0]; ev.target.value = ''; - if (f) this.acquire(f); + if (!f) return; + if (this.hasCamera) { + if (this.inLinea) this.paused = true; + this.analyze(f); + } else { + this.acquire(f); + } }, // Image shown: the saved one from the server when available, else the local one. diff --git a/src/frontend/flask_app/templates/measure/task_execute.html b/src/frontend/flask_app/templates/measure/task_execute.html index 3b75858..b447efc 100644 --- a/src/frontend/flask_app/templates/measure/task_execute.html +++ b/src/frontend/flask_app/templates/measure/task_execute.html @@ -546,12 +546,29 @@ @change="onPick($event)" :disabled="busy"> {% if camera_enabled %} - {# #72: frame dalla camera della stazione, stessa strada di acquire() #} + {# #97: camera del task (o della stazione). Impulsiva: «Acquisisci» rifa grab+check; in linea: «Pausa»/«Riprendi» sul ciclo #} + {% if camera_mode == 'in_linea' %} + + {% else %} + {# #72: frame dalla camera, stessa strada di acquire(); #97: ora grab + analisi senza salvare #} + {% endif %} + {# #97: salva l'ULTIMO frame mostrato (execute); fino ad allora e' solo un'anteprima #} + + {{ _('Anteprima, non registrata') }} {{ _('Acquisizione in corso...') }} {% endif %} {{ _('Elaborazione in corso...') }} @@ -1149,6 +1166,9 @@ window.__visionCfg = { executeUrl: '{{ url_for("measure.api_vision_execute") }}', grabUrl: '{{ url_for("measure.api_vision_grab") }}', + checkUrl: '{{ url_for("measure.api_vision_check") }}', {# #97: analisi senza salvare #} + cameraAvailable: {{ (camera_enabled|default(false))|tojson }}, + modo: {{ (camera_mode|default('impulsiva'))|tojson }}, imageUrlTpl: '{{ url_for("measure.api_vision_result_image", result_id=0) }}', taskId: {{ task.id|tojson }}, taskType: {{ (task.task_type or '')|tojson }}, diff --git a/src/frontend/flask_app/tests/test_measure_vision_check.py b/src/frontend/flask_app/tests/test_measure_vision_check.py new file mode 100644 index 0000000..c0db850 --- /dev/null +++ b/src/frontend/flask_app/tests/test_measure_vision_check.py @@ -0,0 +1,182 @@ +"""gitea:Adriano/TieMeasureFlow#97 - operator: image already analysed on entry. + +The backend (POST /api/vision/check, task.acquisition_json) is built elsewhere: +api_client and requests are mocked here with the contract's shapes. +""" +import io +import json +from unittest.mock import MagicMock, patch + +from tests.conftest import api_get_router +from tests.test_measure_camera_grab import ( + PNG, RECIPE, STATION, TASK, _fake, _station, +) + +ACQ = {"camera_url": "http://acq-host:9000", "camera_source": "camera:601020", "modo": "in_linea"} + + +def _task_with(acq): + return {**TASK, "acquisition_json": acq} + + +# --- grab: the camera of the task, else the station's ------------------------- +def test_grab_with_task_id_uses_task_camera(logged_in_client, mock_api_client, monkeypatch): + _station(mock_api_client, monkeypatch, STATION) + mock_api_client.get.side_effect = api_get_router({"/api/tasks/12": _task_with(ACQ)}) + with patch("blueprints.measure.http_requests.post", return_value=_fake()) as post: + resp = logged_in_client.post("/measure/api/vision/grab", data={"task_id": "12"}) + assert resp.status_code == 200 and resp.data == PNG + args, kwargs = post.call_args + assert args[0] == "http://acq-host:9000/camera/grab" + assert kwargs["json"]["source"] == "camera:601020" + mock_api_client.get_station_by_code.assert_not_called() + + +def test_grab_task_id_in_json_works_too(logged_in_client, mock_api_client, monkeypatch): + _station(mock_api_client, monkeypatch, STATION) + mock_api_client.get.side_effect = api_get_router({"/api/tasks/12": _task_with(ACQ)}) + with patch("blueprints.measure.http_requests.post", return_value=_fake()) as post: + resp = logged_in_client.post("/measure/api/vision/grab", json={"task_id": 12}) + assert resp.status_code == 200 + assert post.call_args[0][0] == "http://acq-host:9000/camera/grab" + + +def test_grab_task_without_acquisition_uses_station(logged_in_client, mock_api_client, monkeypatch): + _station(mock_api_client, monkeypatch, STATION) + mock_api_client.get.side_effect = api_get_router({"/api/tasks/12": _task_with(None)}) + with patch("blueprints.measure.http_requests.post", return_value=_fake()) as post: + resp = logged_in_client.post("/measure/api/vision/grab", data={"task_id": "12"}) + assert resp.status_code == 200 + args, kwargs = post.call_args + assert args[0] == "http://host.docker.internal:8765/camera/grab" + assert kwargs["json"]["source"] == "camera:folder" + + +def test_grab_task_camera_wins_even_if_station_has_none(logged_in_client, mock_api_client, monkeypatch): + _station(mock_api_client, monkeypatch, {**STATION, "camera_url": None}) + mock_api_client.get.side_effect = api_get_router({"/api/tasks/12": _task_with(ACQ)}) + with patch("blueprints.measure.http_requests.post", return_value=_fake()) as post: + resp = logged_in_client.post("/measure/api/vision/grab", data={"task_id": "12"}) + assert resp.status_code == 200 + assert post.call_args[0][0] == "http://acq-host:9000/camera/grab" + + +def test_grab_task_lookup_error_falls_back_to_station(logged_in_client, mock_api_client, monkeypatch): + _station(mock_api_client, monkeypatch, STATION) + mock_api_client.get.side_effect = api_get_router( + {"/api/tasks/12": {"error": True, "status_code": 404, "detail": "x"}}) + with patch("blueprints.measure.http_requests.post", return_value=_fake()) as post: + resp = logged_in_client.post("/measure/api/vision/grab", data={"task_id": "12"}) + assert resp.status_code == 200 + assert post.call_args[0][0] == "http://host.docker.internal:8765/camera/grab" + + +def test_grab_acquisition_as_json_string(logged_in_client, mock_api_client, monkeypatch): + _station(mock_api_client, monkeypatch, {**STATION, "camera_url": None}) + mock_api_client.get.side_effect = api_get_router( + {"/api/tasks/12": _task_with(json.dumps(ACQ))}) + with patch("blueprints.measure.http_requests.post", return_value=_fake()) as post: + resp = logged_in_client.post("/measure/api/vision/grab", data={"task_id": "12"}) + assert resp.status_code == 200 + assert post.call_args[0][0] == "http://acq-host:9000/camera/grab" + + +# --- check proxy --------------------------------------------------------------- +CHECK_OK = { + "outcome": "ok", "overlay": [], "failures": [], "calibration": "ok", + "measurements": [{"subtask_id": 1, "value": 9.9, "pass_fail": "pass"}], +} + + +def test_check_proxy_forwards_multipart(logged_in_client, mock_api_client): + mock_api_client.post.return_value = CHECK_OK + resp = logged_in_client.post( + "/measure/api/vision/check", + data={ + "task_id": "12", "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()["measurements"][0]["pass_fail"] == "pass" + args, kwargs = mock_api_client.post.call_args + assert args[0] == "/api/vision/check" + assert kwargs["data"] == {"task_id": "12", "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_check_proxy_without_run_leaves_it_out(logged_in_client, mock_api_client): + mock_api_client.post.return_value = CHECK_OK + logged_in_client.post( + "/measure/api/vision/check", + data={"task_id": "12", "image": (io.BytesIO(b"x"), "a.jpg", "image/jpeg")}, + content_type="multipart/form-data", + ) + assert mock_api_client.post.call_args[1]["data"] == {"task_id": "12"} + + +def test_check_proxy_requires_image(logged_in_client, mock_api_client): + resp = logged_in_client.post("/measure/api/vision/check", data={"task_id": "12"}) + assert resp.status_code == 400 + mock_api_client.post.assert_not_called() + + +def test_check_proxy_relays_backend_error(logged_in_client, mock_api_client): + mock_api_client.post.return_value = {"error": True, "status_code": 422, "detail": "nessun grafo"} + resp = logged_in_client.post( + "/measure/api/vision/check", + 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"] == "nessun grafo" + + +def test_check_requires_login(client): + resp = client.post("/measure/api/vision/check") + assert resp.status_code in (302, 401) + + +# --- execute page: buttons and config ------------------------------------------- +def _page(logged_in_client, mock_api_client, monkeypatch, station, task): + _station(mock_api_client, monkeypatch, station) + 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, + }) + return logged_in_client.get("/measure/execute/12").get_data(as_text=True) + + +def test_page_impulsive_has_acquire_and_record(logged_in_client, mock_api_client, monkeypatch): + html = _page(logged_in_client, mock_api_client, monkeypatch, STATION, TASK) + assert 'cameraAvailable: true' in html + assert 'modo: "impulsiva"' in html + assert "/measure/api/vision/check" in html + assert "grab()" in html and "record()" in html + assert "togglePause()" not in html + assert "Anteprima, non registrata" in html + + +def test_page_in_line_has_pause_and_record(logged_in_client, mock_api_client, monkeypatch): + html = _page(logged_in_client, mock_api_client, monkeypatch, STATION, _task_with(ACQ)) + assert 'modo: "in_linea"' in html + assert "togglePause()" in html and "record()" in html + assert "grab()" not in html # the loop grabs by itself + + +def test_page_task_camera_enables_it_without_station_camera(logged_in_client, mock_api_client, monkeypatch): + html = _page(logged_in_client, mock_api_client, monkeypatch, + {**STATION, "camera_url": None}, _task_with({**ACQ, "modo": "impulsiva"})) + assert 'cameraAvailable: true' in html + assert "grab()" in html + + +def test_page_without_camera_is_as_before(logged_in_client, mock_api_client, monkeypatch): + html = _page(logged_in_client, mock_api_client, monkeypatch, + {**STATION, "camera_url": None}, TASK) + assert 'cameraAvailable: false' in html + assert "record()" not in html and "togglePause()" not in html and "grab()" not in html