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:
2026-10-07 11:54:27 +02:00
parent 84a4b5f34d
commit accf416cc9
6 changed files with 468 additions and 59 deletions
+106 -17
View File
@@ -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}
+94 -7
View File
@@ -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"] == []