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:
+117
-22
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user