feat(vision): il runner, unica implementazione dell'esecuzione
Aggiunge il sottomodulo vendor/visionsuite (pin f095fcc), src/vision/runner.py (run_graph/engine_version, pura libreria: niente HTTP, niente DB) e i test. Deviazione dal brief: vs-task/vs-measure/vs-pm2d dichiarano requires-python>=3.13 a monte, mentre il monorepo resta a 3.11. L'extra `vision` porta marker `python_version>='3.13'` cosi' la sync di base (server+client+dev, 3.11) resta intatta; per lavorare sulla vision serve `uv sync --extra vision --extra dev --python 3.13`. Ricade sul Task 3: il worker (Dockerfile.vision) non puo' partire da python:3.11-slim come previsto dal suo brief, deve usare 3.13.
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
"""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 subprocess
|
||||
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
|
||||
|
||||
_SUBMODULE = Path(__file__).resolve().parents[2] / "vendor" / "visionsuite"
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def engine_version() -> str:
|
||||
"""The pinned VisionSuite commit: stamped on every number produced."""
|
||||
return subprocess.check_output(
|
||||
["git", "-C", str(_SUBMODULE), "rev-parse", "HEAD"],
|
||||
text=True,
|
||||
).strip()
|
||||
|
||||
|
||||
def run_graph(
|
||||
image: Any,
|
||||
graph: dict,
|
||||
calibration: dict | None = None,
|
||||
) -> RunOutcome:
|
||||
"""Run a serialised vs-task 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.
|
||||
"""
|
||||
task = Task.from_dict(graph)
|
||||
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)
|
||||
|
||||
outputs: dict[str, float] = {}
|
||||
failures: list[ToolFailure] = []
|
||||
for result in report:
|
||||
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():
|
||||
if isinstance(value, (int, float)):
|
||||
outputs[f"{result.tool_id}.{name}"] = float(value)
|
||||
|
||||
return RunOutcome(
|
||||
outputs=outputs,
|
||||
failures=failures,
|
||||
engine_version=engine_version(),
|
||||
duration_ms=elapsed_ms,
|
||||
)
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Il runner: grafo più immagine, numeri fuori. Niente HTTP, niente database."""
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from src.vision.runner import RunOutcome, engine_version, run_graph
|
||||
|
||||
|
||||
def a_disc(radius: int = 60, size: int = 400) -> np.ndarray:
|
||||
"""Un disco chiaro su fondo scuro: un pezzo finto ma misurabile."""
|
||||
image = np.zeros((size, size), dtype=np.uint8)
|
||||
yy, xx = np.ogrid[:size, :size]
|
||||
centre = size // 2
|
||||
image[(yy - centre) ** 2 + (xx - centre) ** 2 <= radius ** 2] = 255
|
||||
return image
|
||||
|
||||
|
||||
def a_circle_graph() -> dict:
|
||||
"""Un grafo di due strumenti, costruito con l'API di vs-task."""
|
||||
from visionsuite.task import Task
|
||||
from visionsuite.task.tools import DetectCircleTool, DiameterTool
|
||||
|
||||
task = Task(name="prova")
|
||||
found = task.add(DetectCircleTool(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.link("circle", found, "circle")
|
||||
return task.to_dict()
|
||||
|
||||
|
||||
def test_the_graph_produces_named_outputs():
|
||||
outcome = run_graph(a_disc(), a_circle_graph())
|
||||
|
||||
assert isinstance(outcome, RunOutcome)
|
||||
assert outcome.failures == []
|
||||
# Le chiavi sono «id dello strumento punto nome dell'uscita»: è la stessa
|
||||
# forma che la subtask salva in vision_output.
|
||||
assert any(key.endswith(".diameter") for key in outcome.outputs)
|
||||
diameter = next(v for k, v in outcome.outputs.items() if k.endswith(".diameter"))
|
||||
assert diameter == pytest.approx(120.0, abs=4.0)
|
||||
|
||||
|
||||
def test_a_broken_tool_does_not_sink_the_others():
|
||||
"""vs-task non ferma l'esecuzione su un errore: il runner lo riporta."""
|
||||
graph = a_circle_graph()
|
||||
graph["tools"].append(
|
||||
{"id": "ghost", "kind": "DetectCircle", "name": "assente",
|
||||
"properties": {"center_row": 5.0, "center_col": 5.0, "radius": 900.0}}
|
||||
)
|
||||
|
||||
outcome = run_graph(a_disc(), graph)
|
||||
|
||||
assert [f.tool_id for f in outcome.failures] == ["ghost"]
|
||||
assert any(key.endswith(".diameter") for key in outcome.outputs)
|
||||
|
||||
|
||||
def test_the_engine_says_which_version_it_is():
|
||||
version = engine_version()
|
||||
assert len(version) == 40 # un SHA di git, per intero
|
||||
Reference in New Issue
Block a user