Motore v2: run_graph con recipe_dir/calibrazione/overlay, /validate, test (#46)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -92,6 +92,8 @@ SESSION_COOKIE_SECURE=true
|
|||||||
VISION_RUN_TIMEOUT_S=100
|
VISION_RUN_TIMEOUT_S=100
|
||||||
# Quante esecuzioni di visione in parallelo accetta il worker.
|
# Quante esecuzioni di visione in parallelo accetta il worker.
|
||||||
VISION_MAX_CONCURRENCY=2
|
VISION_MAX_CONCURRENCY=2
|
||||||
|
# #46: radice sotto cui il worker legge le cartelle ricetta (recipe_dir relativo).
|
||||||
|
VISION_RECIPES_ROOT=/app/uploads
|
||||||
|
|
||||||
# --- Docker ---
|
# --- Docker ---
|
||||||
# DB_ROOT_PASSWORD: in produzione (docker-compose.yml) obbligatoria, senza default.
|
# DB_ROOT_PASSWORD: in produzione (docker-compose.yml) obbligatoria, senza default.
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ services:
|
|||||||
VISION_ENGINE_VERSION: ${VISION_ENGINE_VERSION:-}
|
VISION_ENGINE_VERSION: ${VISION_ENGINE_VERSION:-}
|
||||||
container_name: tmflow-vision
|
container_name: tmflow-vision
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
# #46: radice delle cartelle ricetta (volume uploads, sola lettura)
|
||||||
|
VISION_RECIPES_ROOT: /app/uploads
|
||||||
volumes:
|
volumes:
|
||||||
- upload_data:/app/uploads:ro
|
- upload_data:/app/uploads:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
+115
-20
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
@@ -18,6 +19,9 @@ from typing import Any
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
from visionsuite.core.image import Image
|
from visionsuite.core.image import Image
|
||||||
from visionsuite.task import Task
|
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"
|
_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
|
# 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.
|
# number the caller must not measure a part by. Value is the reason.
|
||||||
invalid_outputs: dict[str, str] = field(default_factory=dict)
|
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)
|
@lru_cache(maxsize=1)
|
||||||
@@ -97,12 +105,46 @@ def engine_version() -> str:
|
|||||||
return _engine_version_from_git()
|
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(
|
def run_graph(
|
||||||
image: Any,
|
image: Any,
|
||||||
graph: dict,
|
graph: dict,
|
||||||
calibration: dict | None = None,
|
calibration: dict | None = None,
|
||||||
|
recipe_dir: Any = None,
|
||||||
) -> RunOutcome:
|
) -> 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
|
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
|
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
|
wrapped into the `visionsuite.core.image.Image` that vs-measure's tools
|
||||||
actually read via `.pixels`; an `Image` passed in is used as-is.
|
actually read via `.pixels`; an `Image` passed in is used as-is.
|
||||||
|
|
||||||
Two phases, on purpose: building the task from `graph` can fail on
|
`recipe_dir` is the folder of the recipe's files (meta.dxf, meta.models,
|
||||||
nothing but the caller's input (a bad schema version, a tool with no
|
...); without it an empty temporary folder is used, so a recipe that needs
|
||||||
"kind", a graph that is not a dict at all) - that is `GraphBuildError`,
|
a file fails as a malformed graph instead of reading from the CWD.
|
||||||
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
|
Two phases, on purpose: building the session can fail on nothing but the
|
||||||
the worker must not call it one.
|
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:
|
load_tools() # #46: registra gli strumenti degli entry point di vs-tools
|
||||||
task = Task.from_dict(graph)
|
with tempfile.TemporaryDirectory() as empty_dir:
|
||||||
except (KeyError, TypeError, AttributeError, ValueError) as exc:
|
session = _open_session(graph, recipe_dir, calibration, empty_dir)
|
||||||
raise GraphBuildError(
|
wrapped_image = (
|
||||||
f"malformed graph: {type(exc).__name__}: {exc}"
|
image if isinstance(image, Image) else Image(pixels=np.asarray(image))
|
||||||
) 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()
|
started = time.monotonic()
|
||||||
report = task.run(context=context)
|
report = session.run(wrapped_image)
|
||||||
elapsed_ms = int((time.monotonic() - started) * 1000)
|
elapsed_ms = int((time.monotonic() - started) * 1000)
|
||||||
|
|
||||||
outputs: dict[str, float] = {}
|
outputs: dict[str, float] = {}
|
||||||
failures: list[ToolFailure] = []
|
failures: list[ToolFailure] = []
|
||||||
invalid_outputs: dict[str, str] = {}
|
invalid_outputs: dict[str, str] = {}
|
||||||
|
overlay: list[dict] = []
|
||||||
for result in report:
|
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:
|
if not result.ok:
|
||||||
failures.append(ToolFailure(
|
failures.append(ToolFailure(
|
||||||
tool_id=result.tool_id,
|
tool_id=result.tool_id,
|
||||||
@@ -164,4 +208,55 @@ def run_graph(
|
|||||||
engine_version=engine_version(),
|
engine_version=engine_version(),
|
||||||
duration_ms=elapsed_ms,
|
duration_ms=elapsed_ms,
|
||||||
invalid_outputs=invalid_outputs,
|
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
|
||||||
|
|||||||
+146
-13
@@ -34,14 +34,16 @@ def a_disc(radius: int = 60, size: int = 400) -> np.ndarray:
|
|||||||
def a_circle_graph() -> dict:
|
def a_circle_graph() -> dict:
|
||||||
"""A two-tool graph, built with vs-task's own API."""
|
"""A two-tool graph, built with vs-task's own API."""
|
||||||
from visionsuite.task import Task
|
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")
|
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.center_row, found.center_col, found.radius = 200.0, 200.0, 60.0
|
||||||
found.scan_size = 30.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")
|
quota.link("circle", found, "circle")
|
||||||
return task.to_dict()
|
return task.to_dict()
|
||||||
|
|
||||||
@@ -63,7 +65,7 @@ def test_a_broken_tool_does_not_sink_the_others():
|
|||||||
graph = a_circle_graph()
|
graph = a_circle_graph()
|
||||||
graph["tools"].append(
|
graph["tools"].append(
|
||||||
{"id": "ghost", "kind": "DetectCircle", "name": "assente",
|
{"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)
|
outcome = run_graph(a_disc(), graph)
|
||||||
@@ -81,14 +83,21 @@ class _FakeResult:
|
|||||||
self.ok = ok
|
self.ok = ok
|
||||||
self.outputs = outputs or {}
|
self.outputs = outputs or {}
|
||||||
self.error = error
|
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):
|
def test_a_nan_output_is_dropped_not_measured(monkeypatch):
|
||||||
"""vs-measure emits NaN on an empty slice (caliper.py:112 per the
|
"""vs-measure emits NaN on an empty slice (caliper.py:112 per the
|
||||||
revisione) - a NaN must not become a quote's value (#16)."""
|
revisione) - a NaN must not become a quote's value (#16)."""
|
||||||
def fake_run(self, context=None):
|
def fake_run(self, image, tag=None):
|
||||||
return [_FakeResult("t1", "diametro", True, outputs={"diameter": float("nan")})]
|
return _FakeReport([_FakeResult("t1", "diametro", True, outputs={"diameter": float("nan")})])
|
||||||
monkeypatch.setattr(runner.Task, "run", fake_run)
|
monkeypatch.setattr(runner.Session, "run", fake_run)
|
||||||
|
|
||||||
outcome = run_graph(a_disc(), a_circle_graph())
|
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 test_an_infinite_output_is_dropped_not_measured(monkeypatch):
|
||||||
def fake_run(self, context=None):
|
def fake_run(self, image, tag=None):
|
||||||
return [_FakeResult("t1", "diametro", True, outputs={"diameter": float("inf")})]
|
return _FakeReport([_FakeResult("t1", "diametro", True, outputs={"diameter": float("inf")})])
|
||||||
monkeypatch.setattr(runner.Task, "run", fake_run)
|
monkeypatch.setattr(runner.Session, "run", fake_run)
|
||||||
|
|
||||||
outcome = run_graph(a_disc(), a_circle_graph())
|
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):
|
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."""
|
"""bool is a subclass of int - a flag output must not become a quote."""
|
||||||
def fake_run(self, context=None):
|
def fake_run(self, image, tag=None):
|
||||||
return [_FakeResult("t1", "flag", True, outputs={"present": True})]
|
return _FakeReport([_FakeResult("t1", "flag", True, outputs={"present": True})])
|
||||||
monkeypatch.setattr(runner.Task, "run", fake_run)
|
monkeypatch.setattr(runner.Session, "run", fake_run)
|
||||||
|
|
||||||
outcome = run_graph(a_disc(), a_circle_graph())
|
outcome = run_graph(a_disc(), a_circle_graph())
|
||||||
|
|
||||||
@@ -155,3 +164,127 @@ def test_engine_version_raises_rather_than_inventing_one(monkeypatch):
|
|||||||
engine_version()
|
engine_version()
|
||||||
finally:
|
finally:
|
||||||
runner._engine_version_from_git.cache_clear()
|
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 == []
|
||||||
|
|||||||
+106
-17
@@ -10,12 +10,19 @@ import asyncio
|
|||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile, status
|
from fastapi import FastAPI, File, Form, HTTPException, UploadFile, status
|
||||||
from PIL import Image, UnidentifiedImageError
|
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")
|
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)
|
_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):
|
class _ImageDecodeError(Exception):
|
||||||
"""The upload is not a decodable image - raised inside the worker thread
|
"""The upload is not a decodable image - raised inside the worker thread
|
||||||
so it survives the trip through `asyncio.to_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()}
|
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
|
"""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 -
|
`run_graph`. Runs in a worker thread (see `run` below) so the event loop -
|
||||||
and therefore `/health` - stays responsive while it executes. Once this
|
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"))
|
frame = np.array(Image.open(io.BytesIO(content)).convert("L"))
|
||||||
except (UnidentifiedImageError, OSError) as exc:
|
except (UnidentifiedImageError, OSError) as exc:
|
||||||
raise _ImageDecodeError(str(exc)) from 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")
|
@app.post("/run")
|
||||||
async def run(
|
async def run(
|
||||||
image: UploadFile = File(...),
|
image: UploadFile = File(...),
|
||||||
graph: str = Form(...),
|
graph: str = Form(...),
|
||||||
|
recipe_dir: str | None = Form(None),
|
||||||
|
calibration: str | None = Form(None),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
try:
|
parsed_graph = _parse_graph(graph)
|
||||||
parsed_graph = json.loads(graph)
|
resolved_dir = _resolve_recipe_dir(recipe_dir)
|
||||||
except json.JSONDecodeError as exc:
|
parsed_calibration = _parse_calibration(calibration)
|
||||||
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__}",
|
|
||||||
)
|
|
||||||
|
|
||||||
content = await image.read()
|
content = await image.read()
|
||||||
|
|
||||||
@@ -89,7 +154,13 @@ async def run(
|
|||||||
async with _run_slots:
|
async with _run_slots:
|
||||||
try:
|
try:
|
||||||
outcome = await asyncio.wait_for(
|
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,
|
timeout=_RUN_TIMEOUT_S,
|
||||||
)
|
)
|
||||||
except asyncio.TimeoutError as exc:
|
except asyncio.TimeoutError as exc:
|
||||||
@@ -130,4 +201,22 @@ async def run(
|
|||||||
"engine_version": outcome.engine_version,
|
"engine_version": outcome.engine_version,
|
||||||
"duration_ms": outcome.duration_ms,
|
"duration_ms": outcome.duration_ms,
|
||||||
"invalid_outputs": outcome.invalid_outputs,
|
"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}
|
||||||
|
|||||||
@@ -45,13 +45,15 @@ def a_disc_png(radius: int = 60, size: int = 400) -> bytes:
|
|||||||
|
|
||||||
def a_circle_graph() -> dict:
|
def a_circle_graph() -> dict:
|
||||||
from visionsuite.task import Task
|
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")
|
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.center_row, found.center_col, found.radius = 200.0, 200.0, 60.0
|
||||||
found.scan_size = 30.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")
|
quota.link("circle", found, "circle")
|
||||||
return task.to_dict()
|
return task.to_dict()
|
||||||
|
|
||||||
@@ -140,7 +142,7 @@ async def test_a_graph_that_is_not_an_object_is_refused():
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_a_tool_without_a_kind_is_refused_not_crashed():
|
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)
|
transport = ASGITransport(app=app)
|
||||||
async with AsyncClient(transport=transport, base_url="http://worker") as client:
|
async with AsyncClient(transport=transport, base_url="http://worker") as client:
|
||||||
response = await client.post(
|
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 response.status_code == 422
|
||||||
assert "malformed graph" in response.json()["detail"].lower()
|
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)
|
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
|
time.sleep(0.5) # off the event loop - it runs inside asyncio.to_thread
|
||||||
return runner.RunOutcome(engine_version="a" * 40)
|
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_slots", asyncio.Semaphore(1))
|
||||||
monkeypatch.setattr(worker_main, "_RUN_TIMEOUT_S", 2.0)
|
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)
|
time.sleep(0.3)
|
||||||
return runner.RunOutcome(engine_version="a" * 40)
|
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 second_response.status_code == 503
|
||||||
assert first_response.status_code == 200
|
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"] == []
|
||||||
|
|||||||
Reference in New Issue
Block a user