From accf416cc91c5adaefa30142ccc140d67c0e63c3 Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Wed, 7 Oct 2026 11:54:27 +0200 Subject: [PATCH] Motore v2: run_graph con recipe_dir/calibrazione/overlay, /validate, test (#46) Co-Authored-By: Claude Sonnet 5.5 --- .env.example | 2 + docker-compose.yml | 3 + src/vision/runner.py | 139 +++++++++++++++++---- src/vision/tests/test_runner.py | 159 +++++++++++++++++++++++-- src/vision_worker/main.py | 123 ++++++++++++++++--- src/vision_worker/tests/test_worker.py | 101 ++++++++++++++-- 6 files changed, 468 insertions(+), 59 deletions(-) diff --git a/.env.example b/.env.example index b01fa5b..c006966 100644 --- a/.env.example +++ b/.env.example @@ -92,6 +92,8 @@ SESSION_COOKIE_SECURE=true VISION_RUN_TIMEOUT_S=100 # Quante esecuzioni di visione in parallelo accetta il worker. VISION_MAX_CONCURRENCY=2 +# #46: radice sotto cui il worker legge le cartelle ricetta (recipe_dir relativo). +VISION_RECIPES_ROOT=/app/uploads # --- Docker --- # DB_ROOT_PASSWORD: in produzione (docker-compose.yml) obbligatoria, senza default. diff --git a/docker-compose.yml b/docker-compose.yml index 33ef3e1..440743a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -71,6 +71,9 @@ services: VISION_ENGINE_VERSION: ${VISION_ENGINE_VERSION:-} container_name: tmflow-vision restart: unless-stopped + environment: + # #46: radice delle cartelle ricetta (volume uploads, sola lettura) + VISION_RECIPES_ROOT: /app/uploads volumes: - upload_data:/app/uploads:ro healthcheck: diff --git a/src/vision/runner.py b/src/vision/runner.py index 5a6b29c..4c375c9 100644 --- a/src/vision/runner.py +++ b/src/vision/runner.py @@ -9,6 +9,7 @@ from __future__ import annotations import math import os import subprocess +import tempfile import time from dataclasses import dataclass, field from functools import lru_cache @@ -18,6 +19,9 @@ from typing import Any import numpy as np from visionsuite.core.image import Image from visionsuite.task import Task +from visionsuite.task.graph import RecipeError # #46: errori di ricetta v2 +from visionsuite.task.registry import load_tools +from visionsuite.task.session import Session _SUBMODULE = Path(__file__).resolve().parents[2] / "vendor" / "visionsuite" @@ -49,6 +53,10 @@ class RunOutcome: # Kept apart from `failures`: the tool did not raise, it just produced a # number the caller must not measure a part by. Value is the reason. invalid_outputs: dict[str, str] = field(default_factory=dict) + # #46: contorni di tutti gli strumenti (px immagine, con tool_id) e esito + # del TaskReport (informativo: il verdetto quote lo dà il backend). + overlay: list[dict] = field(default_factory=list) + outcome: str | None = None @lru_cache(maxsize=1) @@ -97,12 +105,46 @@ def engine_version() -> str: return _engine_version_from_git() +def _scale_calibration(calibration: dict | None): + """Dict {"mm_per_px"|"px_per_mm"} -> ScaleCalibration (gli strumenti di + quota convertono con `in_millimetres`, che non accetta un dict).""" + if calibration is None: + return None + from visionsuite.calibration import ScaleCalibration + + if not isinstance(calibration, dict) or not ( + calibration.get("mm_per_px") or calibration.get("px_per_mm") + ): + raise GraphBuildError( + "calibration must be {'mm_per_px': float} or {'px_per_mm': float}" + ) + key = "mm_per_px" if calibration.get("mm_per_px") else "px_per_mm" + return ScaleCalibration(**{key: float(calibration[key])}) + + +def _open_session(graph: dict, recipe_dir: Any, calibration: dict | None, + workdir: str) -> Session: + """Costruisce la Session v2; RecipeError e grafo malformato -> GraphBuildError.""" + cal = _scale_calibration(calibration) + try: + return Session.from_dict( + graph, Path(recipe_dir) if recipe_dir else Path(workdir), cal, + ) + except RecipeError as exc: + raise GraphBuildError(f"malformed graph: {exc}") from exc + except (KeyError, TypeError, AttributeError, ValueError) as exc: + raise GraphBuildError( + f"malformed graph: {type(exc).__name__}: {exc}" + ) from exc + + def run_graph( image: Any, graph: dict, calibration: dict | None = None, + recipe_dir: Any = None, ) -> RunOutcome: - """Run a serialised vs-task graph against one image. + """Run a serialised vs-task v2 graph against one image. A tool that fails does not stop the others - on a crooked piece an edge may simply not be there, and the other quotes are still wanted. Failures come @@ -112,33 +154,35 @@ def run_graph( wrapped into the `visionsuite.core.image.Image` that vs-measure's tools actually read via `.pixels`; an `Image` passed in is used as-is. - Two phases, on purpose: building the task from `graph` can fail on - nothing but the caller's input (a bad schema version, a tool with no - "kind", a graph that is not a dict at all) - that is `GraphBuildError`, - a 422 for the worker. Once the task exists, `task.run()` is the engine's - own business; an exception from there is not the caller's mistake, and - the worker must not call it one. + `recipe_dir` is the folder of the recipe's files (meta.dxf, meta.models, + ...); without it an empty temporary folder is used, so a recipe that needs + a file fails as a malformed graph instead of reading from the CWD. + + Two phases, on purpose: building the session can fail on nothing but the + caller's input (a bad schema version, a tool with no "kind", a missing + file) - that is `GraphBuildError`, a 422 for the worker. Once it exists, + running is the engine's own business; an exception from there is not the + caller's mistake, and the worker must not call it one. """ - try: - task = Task.from_dict(graph) - except (KeyError, TypeError, AttributeError, ValueError) as exc: - raise GraphBuildError( - f"malformed graph: {type(exc).__name__}: {exc}" - ) from exc - - wrapped_image = image if isinstance(image, Image) else Image(pixels=np.asarray(image)) - context: dict[str, Any] = {"image": wrapped_image} - if calibration is not None: - context["calibration"] = calibration - - started = time.monotonic() - report = task.run(context=context) - elapsed_ms = int((time.monotonic() - started) * 1000) + load_tools() # #46: registra gli strumenti degli entry point di vs-tools + with tempfile.TemporaryDirectory() as empty_dir: + session = _open_session(graph, recipe_dir, calibration, empty_dir) + wrapped_image = ( + image if isinstance(image, Image) else Image(pixels=np.asarray(image)) + ) + started = time.monotonic() + report = session.run(wrapped_image) + elapsed_ms = int((time.monotonic() - started) * 1000) outputs: dict[str, float] = {} failures: list[ToolFailure] = [] invalid_outputs: dict[str, str] = {} + overlay: list[dict] = [] for result in report: + # #46: overlay di ogni strumento, con il tool_id di chi lo ha prodotto + for item in result.overlay or []: + if isinstance(item, dict): + overlay.append({**item, "tool_id": result.tool_id}) if not result.ok: failures.append(ToolFailure( tool_id=result.tool_id, @@ -164,4 +208,55 @@ def run_graph( engine_version=engine_version(), duration_ms=elapsed_ms, invalid_outputs=invalid_outputs, + overlay=overlay, + outcome=report.outcome, ) + + +def _is_number(value: Any) -> bool: + return isinstance(value, (int, float)) and not isinstance(value, bool) + + +def validate_graph( + graph: dict, recipe_dir: Any = None +) -> tuple[bool, list[str], list[dict]]: + """Validate a v2 recipe without an image: (ok, errors, numeric outputs). + + Not a draft: a missing meta file is an error. `outputs` lists the numeric + outputs the tools declare (OUT properties with a numeric default, plus the + `values` of dimension tools), with quantity/unit when declared. + """ + load_tools() + if not isinstance(graph, dict): + return False, ["graph must be a JSON object"], [] + try: + task = Task.from_dict( + graph, recipe_dir=Path(recipe_dir) if recipe_dir else None + ) + except RecipeError as exc: + return False, list(exc.errors), [] + except (KeyError, TypeError, AttributeError, ValueError) as exc: + return False, [f"{type(exc).__name__}: {exc}"], [] + + outputs: list[dict] = [] + for tool in task.tools: + value_quantities = getattr(tool, "value_quantities", None) or {} + values = tuple(getattr(tool, "values", ()) or ()) + for prop in tool.outputs(): + if not (_is_number(prop.default) or prop.name in values): + continue + quantity = value_quantities.get(prop.name, getattr(tool, "quantity", None)) + if not isinstance(quantity, str): + quantity = None + unit = None + if quantity == "length": + nominal_unit = getattr(tool, "nominal_unit", None) + unit = nominal_unit if isinstance(nominal_unit, str) else None + elif quantity == "angle": + unit = "deg" + outputs.append({ + "key": f"{tool.id}.{prop.name}", + "quantity": quantity, + "unit": unit, + }) + return True, [], outputs diff --git a/src/vision/tests/test_runner.py b/src/vision/tests/test_runner.py index 56bb778..2273ee1 100644 --- a/src/vision/tests/test_runner.py +++ b/src/vision/tests/test_runner.py @@ -34,14 +34,16 @@ def a_disc(radius: int = 60, size: int = 400) -> np.ndarray: def a_circle_graph() -> dict: """A two-tool graph, built with vs-task's own API.""" from visionsuite.task import Task - from visionsuite.task.tools import DetectCircleTool, DiameterTool + from visionsuite.tools.detect import DetectCircle + from visionsuite.tools.dimensions import Diameter task = Task(name="prova") - found = task.add(DetectCircleTool(name="disco")) + found = task.add(DetectCircle(name="disco")) found.center_row, found.center_col, found.radius = 200.0, 200.0, 60.0 found.scan_size = 30.0 - quota = task.add(DiameterTool(name="diametro")) + quota = task.add(Diameter(name="diametro")) + quota.nominal_unit = "px" # #46: v2, senza calibrazione la quota resta in px quota.link("circle", found, "circle") return task.to_dict() @@ -63,7 +65,7 @@ def test_a_broken_tool_does_not_sink_the_others(): graph = a_circle_graph() graph["tools"].append( {"id": "ghost", "kind": "DetectCircle", "name": "assente", - "properties": {"center_row": 5.0, "center_col": 5.0, "radius": 900.0}} + "values": {"center_row": 5.0, "center_col": 5.0, "radius": 900.0}} ) outcome = run_graph(a_disc(), graph) @@ -81,14 +83,21 @@ class _FakeResult: self.ok = ok self.outputs = outputs or {} self.error = error + self.overlay = [] # #46: v2, ogni risultato porta l'overlay + + +class _FakeReport(list): + """#46: run_graph ora legge `.outcome` dal report di Session.run.""" + + outcome = "pass" def test_a_nan_output_is_dropped_not_measured(monkeypatch): """vs-measure emits NaN on an empty slice (caliper.py:112 per the revisione) - a NaN must not become a quote's value (#16).""" - def fake_run(self, context=None): - return [_FakeResult("t1", "diametro", True, outputs={"diameter": float("nan")})] - monkeypatch.setattr(runner.Task, "run", fake_run) + def fake_run(self, image, tag=None): + return _FakeReport([_FakeResult("t1", "diametro", True, outputs={"diameter": float("nan")})]) + monkeypatch.setattr(runner.Session, "run", fake_run) outcome = run_graph(a_disc(), a_circle_graph()) @@ -97,9 +106,9 @@ def test_a_nan_output_is_dropped_not_measured(monkeypatch): def test_an_infinite_output_is_dropped_not_measured(monkeypatch): - def fake_run(self, context=None): - return [_FakeResult("t1", "diametro", True, outputs={"diameter": float("inf")})] - monkeypatch.setattr(runner.Task, "run", fake_run) + def fake_run(self, image, tag=None): + return _FakeReport([_FakeResult("t1", "diametro", True, outputs={"diameter": float("inf")})]) + monkeypatch.setattr(runner.Session, "run", fake_run) outcome = run_graph(a_disc(), a_circle_graph()) @@ -109,9 +118,9 @@ def test_an_infinite_output_is_dropped_not_measured(monkeypatch): def test_a_bool_output_is_not_treated_as_a_measurable_number(monkeypatch): """bool is a subclass of int - a flag output must not become a quote.""" - def fake_run(self, context=None): - return [_FakeResult("t1", "flag", True, outputs={"present": True})] - monkeypatch.setattr(runner.Task, "run", fake_run) + def fake_run(self, image, tag=None): + return _FakeReport([_FakeResult("t1", "flag", True, outputs={"present": True})]) + monkeypatch.setattr(runner.Session, "run", fake_run) outcome = run_graph(a_disc(), a_circle_graph()) @@ -155,3 +164,127 @@ def test_engine_version_raises_rather_than_inventing_one(monkeypatch): engine_version() finally: runner._engine_version_from_git.cache_clear() + + +# --------------------------------------------------------------------------- +# #46: ricette v2 complete - overlay, calibrazione, recipe_dir con DXF. +# --------------------------------------------------------------------------- + +import hashlib +import shutil +from pathlib import Path + +import cv2 + +_VS = Path(__file__).resolve().parents[3] / "vendor" / "visionsuite" +_SAMPLE_DXF = _VS / "packages" / "vs-dxf" / "tests" / "fixtures" / "sample.dxf" +# Il disegno di prova: rettangolo 200x100 mm con foro r=10 mm. +_PX_PER_MM = 4.0 + + +def _dxf_scene() -> np.ndarray: + """Il pezzo del disegno rasterizzato a 4 px/mm, margine 60 mm.""" + margin = 60.0 + w = int((200.0 + 2 * margin) * _PX_PER_MM) + h = int((100.0 + 2 * margin) * _PX_PER_MM) + img = np.full((h, w), 255, dtype=np.uint8) + cx, cy = w // 2, h // 2 + hw, hh = 200.0 * _PX_PER_MM / 2, 100.0 * _PX_PER_MM / 2 + cv2.rectangle(img, (int(cx - hw), int(cy - hh)), (int(cx + hw), int(cy + hh)), 0, -1) + cv2.circle(img, (cx, cy), int(10.0 * _PX_PER_MM), 255, -1) + return img + + +def _dxf_recipe() -> dict: + """CompareDxf con ancora a due punti, collegati a due DetectPointScan. + + Le due scansioni trovano i bordi sinistro e destro del pezzo una riga + sopra il lato in basso: origine del disegno = angolo in basso a sinistra, + direzione a -> b = asse x del disegno. + """ + h, w = _dxf_scene().shape + cx, cy = w // 2, h // 2 + hw, hh = 200.0 * _PX_PER_MM / 2, 100.0 * _PX_PER_MM / 2 + row = cy + hh - 1.0 + + def scan(tid: str, col: float) -> dict: + return {"id": tid, "name": tid, "kind": "DetectPointScan", + "values": {"start_row": row, "start_col": col - 30.0, + "end_row": row, "end_col": col + 30.0, + "width": 5.0, "select": "first"}, + "links": {}} + + return { + "schema_version": 2, + "name": "dxf", + "meta": {"dxf": {"disegno": { # sha256 obbligatorio in meta (v2) + "path": "disegno.dxf", + "sha256": hashlib.sha256(_SAMPLE_DXF.read_bytes()).hexdigest()}}}, + "tools": [ + scan("t-a", cx - hw), + scan("t-b", cx + hw), + {"id": "t-dxf", "name": "confronto", "kind": "CompareDxf", + "values": {"dxf": "disegno", "mm_per_px": 1.0 / _PX_PER_MM, + "anchor": "two_points", + "band_plus": 8.0, "band_minus": 8.0}, + "links": {"point_a": {"tool": "t-a", "property": "point"}, + "point_b": {"tool": "t-b", "property": "point"}}}, + ], + } + + +def test_a_circle_recipe_returns_overlay_and_outcome(): + outcome = run_graph(a_disc(), a_circle_graph()) + + assert outcome.outcome in ("pass", "warning", "fail") + assert outcome.overlay, "DetectCircle must draw something" + assert all("tool_id" in item for item in outcome.overlay) + + +def test_calibration_dict_gives_mm_quotes(): + graph = a_circle_graph() + for tool in graph["tools"]: + if tool["kind"] == "Diameter": + tool["values"]["nominal_unit"] = "mm" + + outcome = run_graph(a_disc(), graph, calibration={"mm_per_px": 0.5}) + + assert outcome.failures == [] + diameter = next(v for k, v in outcome.outputs.items() if k.endswith(".value")) + assert diameter == pytest.approx(60.0, abs=2.0) # 120 px * 0.5 mm/px + + +def test_compare_dxf_two_points_with_recipe_dir(tmp_path): + shutil.copy(_SAMPLE_DXF, tmp_path / "disegno.dxf") + + outcome = run_graph(_dxf_scene(), _dxf_recipe(), recipe_dir=tmp_path) + + assert outcome.failures == [] + assert outcome.outputs["t-dxf.n_points"] > 1000 + assert outcome.outputs["t-dxf.points_in_pct"] > 95.0 + roles = {item.get("role") for item in outcome.overlay} + assert {"reference", "found"} <= roles + assert "t-dxf" in {item["tool_id"] for item in outcome.overlay} + + +def test_missing_meta_file_is_a_graph_build_error(tmp_path): + with pytest.raises(runner.GraphBuildError): + run_graph(_dxf_scene(), _dxf_recipe(), recipe_dir=tmp_path) + + +def test_validate_lists_numeric_outputs(tmp_path): + shutil.copy(_SAMPLE_DXF, tmp_path / "disegno.dxf") + graph = _dxf_recipe() + + ok, errors, outputs = runner.validate_graph(graph, tmp_path) + + assert ok, errors + keys = {o["key"] for o in outputs} + assert {"t-dxf.points_in_pct", "t-dxf.n_points"} <= keys + assert all(set(o) == {"key", "quantity", "unit"} for o in outputs) + + +def test_validate_reports_a_missing_file(tmp_path): + ok, errors, outputs = runner.validate_graph(_dxf_recipe(), tmp_path) + + assert not ok and errors and outputs == [] diff --git a/src/vision_worker/main.py b/src/vision_worker/main.py index d62aa6f..25311be 100644 --- a/src/vision_worker/main.py +++ b/src/vision_worker/main.py @@ -10,12 +10,19 @@ import asyncio import io import json import os +from pathlib import Path import numpy as np from fastapi import FastAPI, File, Form, HTTPException, UploadFile, status from PIL import Image, UnidentifiedImageError -from src.vision.runner import GraphBuildError, RunOutcome, engine_version, run_graph +from src.vision.runner import ( + GraphBuildError, + RunOutcome, + engine_version, + run_graph, + validate_graph, +) app = FastAPI(title="TieMeasureFlow Vision Worker", version="0.1.0") @@ -31,6 +38,67 @@ _MAX_CONCURRENCY = int(os.environ.get("VISION_MAX_CONCURRENCY", "2")) _run_slots = asyncio.Semaphore(_MAX_CONCURRENCY) +# #46: radice sotto cui vivono le cartelle delle ricette (volume uploads, +# montato in sola lettura nel worker). `recipe_dir` e' relativo a questa radice. +_RECIPES_ROOT_DEFAULT = "/app/uploads" + + +def _unprocessable(detail: str) -> HTTPException: + return HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=detail + ) + + +def _resolve_recipe_dir(recipe_dir: str | None) -> Path | None: + """Percorso RELATIVO sotto VISION_RECIPES_ROOT -> cartella assoluta. + + Assoluto, `..` o fuori radice (anche via symlink) -> 422. Vuoto -> None. + """ + if recipe_dir is None or not recipe_dir.strip(): + return None + rel = recipe_dir.strip() + # Letto a ogni richiesta (non a import) cosi' i test possono cambiarlo. + root = Path(os.environ.get("VISION_RECIPES_ROOT", _RECIPES_ROOT_DEFAULT)).resolve() + if ( + Path(rel).is_absolute() + or rel.startswith(("/", "\\")) + or ".." in Path(rel.replace("\\", "/")).parts + ): + raise _unprocessable("recipe_dir must be a relative path under the recipes root") + resolved = (root / rel).resolve() + if resolved != root and root not in resolved.parents: + raise _unprocessable("recipe_dir is outside the recipes root") + return resolved + + +def _parse_graph(graph: str) -> dict: + try: + parsed = json.loads(graph) + except json.JSONDecodeError as exc: + raise _unprocessable(f"graph is not valid JSON: {exc}") from exc + if not isinstance(parsed, dict): + raise _unprocessable( + f"graph must be a JSON object, got {type(parsed).__name__}" + ) + return parsed + + +def _parse_calibration(calibration: str | None) -> dict | None: + if calibration is None or not calibration.strip(): + return None + try: + parsed = json.loads(calibration) + except json.JSONDecodeError as exc: + raise _unprocessable(f"calibration is not valid JSON: {exc}") from exc + if not isinstance(parsed, dict) or not ( + parsed.get("mm_per_px") or parsed.get("px_per_mm") + ): + raise _unprocessable( + "calibration must be {'mm_per_px': float} or {'px_per_mm': float}" + ) + return parsed + + class _ImageDecodeError(Exception): """The upload is not a decodable image - raised inside the worker thread so it survives the trip through `asyncio.to_thread`.""" @@ -44,7 +112,12 @@ async def health() -> dict: return {"status": "ok", "engine_version": engine_version()} -def _decode_and_run(content: bytes, graph: dict) -> RunOutcome: +def _decode_and_run( + content: bytes, + graph: dict, + calibration: dict | None = None, + recipe_dir: Path | None = None, +) -> RunOutcome: """The synchronous, CPU/IO-bound half of a request: PIL decode plus `run_graph`. Runs in a worker thread (see `run` below) so the event loop - and therefore `/health` - stays responsive while it executes. Once this @@ -56,27 +129,19 @@ def _decode_and_run(content: bytes, graph: dict) -> RunOutcome: frame = np.array(Image.open(io.BytesIO(content)).convert("L")) except (UnidentifiedImageError, OSError) as exc: raise _ImageDecodeError(str(exc)) from exc - return run_graph(frame, graph) + return run_graph(frame, graph, calibration=calibration, recipe_dir=recipe_dir) @app.post("/run") async def run( image: UploadFile = File(...), graph: str = Form(...), + recipe_dir: str | None = Form(None), + calibration: str | None = Form(None), ) -> dict: - try: - parsed_graph = json.loads(graph) - except json.JSONDecodeError as exc: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, - detail=f"graph is not valid JSON: {exc}", - ) from exc - - if not isinstance(parsed_graph, dict): - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, - detail=f"graph must be a JSON object, got {type(parsed_graph).__name__}", - ) + parsed_graph = _parse_graph(graph) + resolved_dir = _resolve_recipe_dir(recipe_dir) + parsed_calibration = _parse_calibration(calibration) content = await image.read() @@ -89,7 +154,13 @@ async def run( async with _run_slots: try: outcome = await asyncio.wait_for( - asyncio.to_thread(_decode_and_run, content, parsed_graph), + asyncio.to_thread( + _decode_and_run, + content, + parsed_graph, + parsed_calibration, + resolved_dir, + ), timeout=_RUN_TIMEOUT_S, ) except asyncio.TimeoutError as exc: @@ -130,4 +201,22 @@ async def run( "engine_version": outcome.engine_version, "duration_ms": outcome.duration_ms, "invalid_outputs": outcome.invalid_outputs, + # #46: contorni per l'overlay sull'immagine e esito VisionSuite. + "overlay": outcome.overlay, + "outcome": outcome.outcome, } + + +@app.post("/validate") +async def validate( + graph: str = Form(...), + recipe_dir: str | None = Form(None), +) -> dict: + """Validates a v2 recipe (not a draft: a missing meta file is an error) + and lists its numeric outputs. No image, so it is cheap: no run slot.""" + parsed_graph = _parse_graph(graph) + resolved_dir = _resolve_recipe_dir(recipe_dir) + ok, errors, outputs = await asyncio.to_thread( + validate_graph, parsed_graph, resolved_dir + ) + return {"ok": ok, "errors": errors, "outputs": outputs} diff --git a/src/vision_worker/tests/test_worker.py b/src/vision_worker/tests/test_worker.py index 3b812c2..fe7ccfe 100644 --- a/src/vision_worker/tests/test_worker.py +++ b/src/vision_worker/tests/test_worker.py @@ -45,13 +45,15 @@ def a_disc_png(radius: int = 60, size: int = 400) -> bytes: def a_circle_graph() -> dict: from visionsuite.task import Task - from visionsuite.task.tools import DetectCircleTool, DiameterTool + from visionsuite.tools.detect import DetectCircle + from visionsuite.tools.dimensions import Diameter task = Task(name="prova") - found = task.add(DetectCircleTool(name="disco")) + found = task.add(DetectCircle(name="disco")) found.center_row, found.center_col, found.radius = 200.0, 200.0, 60.0 found.scan_size = 30.0 - quota = task.add(DiameterTool(name="diametro")) + quota = task.add(Diameter(name="diametro")) + quota.nominal_unit = "px" # #46: v2, senza calibrazione la quota resta in px quota.link("circle", found, "circle") return task.to_dict() @@ -140,7 +142,7 @@ async def test_a_graph_that_is_not_an_object_is_refused(): @pytest.mark.asyncio async def test_a_tool_without_a_kind_is_refused_not_crashed(): - graph = {"schema_version": 1, "name": "p", "tools": [{"id": "a", "name": "x"}]} + graph = {"schema_version": 2, "name": "p", "tools": [{"id": "a", "name": "x"}]} transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://worker") as client: response = await client.post( @@ -151,7 +153,7 @@ async def test_a_tool_without_a_kind_is_refused_not_crashed(): assert response.status_code == 422 assert "malformed graph" in response.json()["detail"].lower() - assert "kind" in response.json()["detail"].lower() + assert "tipo sconosciuto" in response.json()["detail"].lower() # #46: messaggio v2 # --------------------------------------------------------------------------- @@ -167,7 +169,7 @@ async def test_a_run_past_its_budget_answers_504_and_health_still_answers( ): monkeypatch.setattr(worker_main, "_RUN_TIMEOUT_S", 0.1) - def slow_run_graph(frame, graph): + def slow_run_graph(frame, graph, **kwargs): time.sleep(0.5) # off the event loop - it runs inside asyncio.to_thread return runner.RunOutcome(engine_version="a" * 40) @@ -197,7 +199,7 @@ async def test_a_third_concurrent_run_is_refused_not_queued(monkeypatch): monkeypatch.setattr(worker_main, "_run_slots", asyncio.Semaphore(1)) monkeypatch.setattr(worker_main, "_RUN_TIMEOUT_S", 2.0) - def slow_run_graph(frame, graph): + def slow_run_graph(frame, graph, **kwargs): time.sleep(0.3) return runner.RunOutcome(engine_version="a" * 40) @@ -222,3 +224,88 @@ async def test_a_third_concurrent_run_is_refused_not_queued(monkeypatch): assert second_response.status_code == 503 assert first_response.status_code == 200 + + +# --------------------------------------------------------------------------- +# #46: recipe_dir, calibration, overlay, /validate. +# --------------------------------------------------------------------------- + + +async def _post(path: str, **kwargs): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://worker") as client: + return await client.post(path, **kwargs) + + +@pytest.mark.asyncio +async def test_run_returns_overlay_and_outcome(): + response = await _post( + "/run", + files={"image": ("piece.png", a_disc_png(), "image/png")}, + data={"graph": json.dumps(a_circle_graph())}, + ) + + body = response.json() + assert response.status_code == 200 + assert body["overlay"] and "tool_id" in body["overlay"][0] + assert body["outcome"] in ("pass", "warning", "fail") + + +@pytest.mark.asyncio +async def test_run_accepts_a_calibration(): + graph = a_circle_graph() + for tool in graph["tools"]: + if tool["kind"] == "Diameter": + tool["values"]["nominal_unit"] = "mm" + response = await _post( + "/run", + files={"image": ("piece.png", a_disc_png(), "image/png")}, + data={"graph": json.dumps(graph), + "calibration": json.dumps({"px_per_mm": 2.0})}, + ) + + body = response.json() + assert response.status_code == 200, body + assert body["failures"] == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("bad", ["../etc", "a/../../b", "/abs/path", "C:/x/y"]) +async def test_a_recipe_dir_outside_the_root_is_refused(bad, monkeypatch, tmp_path): + monkeypatch.setenv("VISION_RECIPES_ROOT", str(tmp_path)) + response = await _post( + "/run", + files={"image": ("piece.png", a_disc_png(), "image/png")}, + data={"graph": json.dumps(a_circle_graph()), "recipe_dir": bad}, + ) + assert response.status_code == 422 + + response = await _post( + "/validate", data={"graph": json.dumps(a_circle_graph()), "recipe_dir": bad} + ) + assert response.status_code == 422 + + +@pytest.mark.asyncio +async def test_validate_ok_and_with_a_missing_file(monkeypatch, tmp_path): + monkeypatch.setenv("VISION_RECIPES_ROOT", str(tmp_path)) + response = await _post("/validate", data={"graph": json.dumps(a_circle_graph())}) + body = response.json() + assert response.status_code == 200 and body["ok"] is True + assert any(o["key"].endswith(".diameter") for o in body["outputs"]) + + (tmp_path / "r1").mkdir() + graph = { + "schema_version": 2, "name": "d", + "meta": {"dxf": {"d": {"path": "mancante.dxf"}}}, + "tools": [{"id": "t", "name": "c", "kind": "CompareDxf", + "values": {"dxf": "d", "mm_per_px": 0.25, + "band_plus": 8.0, "band_minus": 8.0}, + "links": {}}], + } + response = await _post( + "/validate", data={"graph": json.dumps(graph), "recipe_dir": "r1"} + ) + body = response.json() + assert response.status_code == 200 + assert body["ok"] is False and body["errors"] and body["outputs"] == []