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