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:
2026-08-16 17:28:09 +02:00
parent 9546ded1e8
commit c5f3366dd1
8 changed files with 474 additions and 1 deletions
View File
+91
View File
@@ -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,
)
View File
+60
View File
@@ -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