accf416cc9
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
263 lines
10 KiB
Python
263 lines
10 KiB
Python
"""Graph plus image in, numbers out.
|
|
|
|
The one implementation of vision execution: the worker beside the server and
|
|
the station agent both call this, and they must agree to the digit. No HTTP, no
|
|
database, no filesystem beyond reading the pinned submodule's commit.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
import os
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from functools import lru_cache
|
|
from pathlib import Path
|
|
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"
|
|
|
|
|
|
class GraphBuildError(ValueError):
|
|
"""`Task.from_dict` refused the graph itself - a bad request, not a
|
|
server fault (a missing "kind", an unknown schema version, a graph that
|
|
is not even a dict). Kept apart from an error raised once the task is
|
|
actually running: the worker maps this one to 422, everything else that
|
|
can go wrong inside `task.run()` to 500."""
|
|
|
|
|
|
@dataclass
|
|
class ToolFailure:
|
|
tool_id: str
|
|
tool_name: str
|
|
error: str
|
|
|
|
|
|
@dataclass
|
|
class RunOutcome:
|
|
outputs: dict[str, float] = field(default_factory=dict)
|
|
failures: list[ToolFailure] = field(default_factory=list)
|
|
engine_version: str = ""
|
|
duration_ms: int = 0
|
|
# A tool that "succeeded" but handed back NaN/inf (vs-measure emits NaN on
|
|
# an empty slice - see caliper.py:112) or a bool (a subclass of int, so
|
|
# `isinstance(x, (int, float))` alone would let it through as a quote).
|
|
# 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)
|
|
def _engine_version_from_git() -> str:
|
|
"""Read the pinned commit from the submodule checkout.
|
|
|
|
Cached because the subprocess call is not free and the answer cannot
|
|
change within a run. Kept separate from `engine_version()` so that the
|
|
(cheap) environment-variable check below is never served a stale answer,
|
|
and so a test can force this path to fail without poisoning the cache
|
|
for real callers.
|
|
"""
|
|
try:
|
|
return subprocess.check_output(
|
|
["git", "-C", str(_SUBMODULE), "rev-parse", "HEAD"],
|
|
text=True,
|
|
stderr=subprocess.PIPE,
|
|
).strip()
|
|
except (OSError, subprocess.CalledProcessError) as exc:
|
|
raise RuntimeError(
|
|
"engine_version: cannot determine which VisionSuite commit this "
|
|
"process is running. VISION_ENGINE_VERSION is not set, and "
|
|
f"`git -C {_SUBMODULE} rev-parse HEAD` failed ({exc}). "
|
|
"In the vision worker container this is expected - it has no "
|
|
"usable .git for the vendored submodule - so the image must be "
|
|
"built with `--build-arg VISION_ENGINE_VERSION=<commit>` "
|
|
"(see Dockerfile.vision). In a development checkout, make sure "
|
|
"the vendor/visionsuite submodule is initialised. Refusing to "
|
|
"guess: a station must not measure with an engine it cannot name."
|
|
) from exc
|
|
|
|
|
|
def engine_version() -> str:
|
|
"""The pinned VisionSuite commit: stamped on every number produced.
|
|
|
|
Resolution order: the VISION_ENGINE_VERSION environment variable (set at
|
|
container build time - Ruling R8, see Dockerfile.vision) if set and
|
|
non-empty; otherwise `git rev-parse HEAD` on the vendored submodule,
|
|
which works in a development checkout. Never falls back to a guess, an
|
|
empty string, or "unknown" - a station running an engine it cannot name
|
|
must refuse to measure rather than produce numbers nobody can trace.
|
|
"""
|
|
stamped = os.environ.get("VISION_ENGINE_VERSION", "").strip()
|
|
if stamped:
|
|
return stamped
|
|
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 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
|
|
back named, they are not swallowed.
|
|
|
|
`image` is whatever the caller already has in hand: a plain numpy array is
|
|
wrapped into the `visionsuite.core.image.Image` that vs-measure's tools
|
|
actually read via `.pixels`; an `Image` passed in is used as-is.
|
|
|
|
`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.
|
|
"""
|
|
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,
|
|
tool_name=result.tool_name,
|
|
error=result.error or "unknown failure",
|
|
))
|
|
continue
|
|
for name, value in result.outputs.items():
|
|
key = f"{result.tool_id}.{name}"
|
|
# bool is a subclass of int - a tool that outputs a flag must not
|
|
# be read as a quote.
|
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
continue
|
|
value = float(value)
|
|
if not math.isfinite(value):
|
|
invalid_outputs[key] = "nan" if value != value else "infinite"
|
|
continue
|
|
outputs[key] = value
|
|
|
|
return RunOutcome(
|
|
outputs=outputs,
|
|
failures=failures,
|
|
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
|