Files
TieMeasureFlow/src/vision/runner.py
T

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