feat(vision): il worker che espone il runner, versione stampata al build

Container FastAPI separato (Dockerfile.vision, python:3.13-slim) che
espone run_graph/engine_version del Task 2 via POST /run e GET /health,
cosi' l'immagine del server principale non importa mai VisionSuite.

engine_version() ora legge VISION_ENGINE_VERSION se impostata, altrimenti
ricade su git rev-parse nel checkout di sviluppo, e non inventa mai un
valore: senza nessuna delle due solleva un errore esplicito. Nel container
il fallback a git non puo' funzionare (.git del submodule punta fuori dal
build context), quindi Dockerfile.vision prende il commit come build arg
e lo fissa in ambiente; i compose file lo passano da VISION_ENGINE_VERSION.

Nessuna porta pubblicata e nessuna label Traefik sul servizio vision: e'
raggiungibile solo dal server, su tmflow-net.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU
This commit is contained in:
2026-08-16 18:12:04 +02:00
parent 9387e7c306
commit 71da162e1f
12 changed files with 334 additions and 6 deletions
View File
+58
View File
@@ -0,0 +1,58 @@
"""The heavy container: the runner behind an internal API.
Separate from the FastAPI server on purpose. The API image stays light, a
VisionSuite upgrade does not restart production traffic, and an execution that
crashes does not take the other tablets' requests down with it.
"""
from __future__ import annotations
import json
import numpy as np
from fastapi import FastAPI, File, Form, HTTPException, UploadFile, status
from PIL import Image
from src.vision.runner import engine_version, run_graph
app = FastAPI(title="TieMeasureFlow Vision Worker", version="0.1.0")
@app.get("/health")
async def health() -> dict:
return {"status": "ok", "engine_version": engine_version()}
@app.post("/run")
async def run(
image: UploadFile = File(...),
graph: str = Form(...),
) -> dict:
try:
parsed_graph = json.loads(graph)
except json.JSONDecodeError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"graph is not valid JSON: {exc}",
) from exc
frame = np.array(Image.open(image.file).convert("L"))
try:
outcome = run_graph(frame, parsed_graph)
except ValueError as exc:
# from_dict refuses a schema version it does not handle; that is a bad
# request, not a server fault.
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
return {
"outputs": outcome.outputs,
"failures": [
{"tool_id": f.tool_id, "tool_name": f.tool_name, "error": f.error}
for f in outcome.failures
],
"engine_version": outcome.engine_version,
"duration_ms": outcome.duration_ms,
}
View File
+91
View File
@@ -0,0 +1,91 @@
"""The worker is a thin shell: it receives an image and a graph, and answers
with numbers."""
import io
import json
import pytest
# Same reasoning as src/vision/tests/test_runner.py: the vision tree (and
# therefore this worker, which imports src.vision.runner) requires Python
# >=3.13. This must run before `import numpy` / `from src.vision_worker.main
# import app` so that `uv run pytest` on the default 3.11 environment reports
# this module as skipped, with a reason, instead of erroring out of
# collection.
pytest.importorskip(
"visionsuite.task",
reason=(
"the vision tree requires Python >=3.13 (vs-task/vs-measure/vs-pm2d "
"declare it upstream); run it with "
"`uv run --python 3.13 --extra vision --extra dev pytest "
"src/vision_worker/tests`"
),
)
import numpy as np
from httpx import ASGITransport, AsyncClient
from PIL import Image
from src.vision_worker.main import app
def a_disc_png(radius: int = 60, size: int = 400) -> bytes:
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
buffer = io.BytesIO()
Image.fromarray(image).save(buffer, format="PNG")
return buffer.getvalue()
def a_circle_graph() -> dict:
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()
@pytest.mark.asyncio
async def test_health_says_which_engine_it_carries():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://worker") as client:
response = await client.get("/health")
assert response.status_code == 200
assert len(response.json()["engine_version"]) == 40
@pytest.mark.asyncio
async def test_run_returns_the_named_outputs():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://worker") as client:
response = await client.post(
"/run",
files={"image": ("piece.png", a_disc_png(), "image/png")},
data={"graph": json.dumps(a_circle_graph())},
)
assert response.status_code == 200
body = response.json()
assert body["failures"] == []
assert any(key.endswith(".diameter") for key in body["outputs"])
@pytest.mark.asyncio
async def test_a_malformed_graph_is_refused_not_crashed():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://worker") as client:
response = await client.post(
"/run",
files={"image": ("piece.png", a_disc_png(), "image/png")},
data={"graph": json.dumps({"schema_version": 99, "tools": []})},
)
assert response.status_code == 422
assert "99" in response.json()["detail"]