Files
TieMeasureFlow/src/backend/services/vision_service.py
T
Adriano ddf7788d77 feat(vision): il server esegue tramite worker e salva misure vere
Aggiunge POST /api/vision/execute: route l'immagine al worker di visione,
mappa le uscite del grafo sulle quote e le salva con save_measurement -
stesso verdetto, stesso gate del fuori tolleranza di ogni altra misura.
Nuova tabella vision_results (una riga per acquisizione, non per quota) e
input_method 'camera' su measurements.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014BBnuACZSCJqXrMYC3LUMU
2026-08-16 18:40:12 +02:00

108 lines
3.6 KiB
Python

"""Route the image to the worker, map the outputs onto the quotes, save.
This module never imports VisionSuite - see test_vision_boundary.py. It speaks
to the worker over HTTP and stays ignorant of how a number was produced.
"""
from __future__ import annotations
import json
import httpx
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.config import settings
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from src.backend.models.orm.vision import VisionResult
from src.backend.services import measurement_service
async def _call_worker(image_bytes: bytes, graph: dict) -> dict:
"""The one place that talks to the worker; the tests replace it."""
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(
f"{settings.vision_worker_url}/run",
files={"image": ("frame.png", image_bytes, "image/png")},
data={"graph": json.dumps(graph)},
)
if response.status_code != 200:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"the vision worker refused the job: {response.text}",
)
return response.json()
async def execute_task(
db: AsyncSession,
task: RecipeTask,
image_bytes: bytes,
measured_by: int,
*,
version_id: int,
lot_number: str | None = None,
serial_number: str | None = None,
production_run_id: int | None = None,
) -> tuple[VisionResult, list[Measurement]]:
if not task.vision_json:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="this task carries no vision graph",
)
quotes = (await db.execute(
select(RecipeSubtask)
.where(RecipeSubtask.task_id == task.id)
.where(RecipeSubtask.vision_output.is_not(None))
)).scalars().all()
if not quotes:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="no quote on this task names a vision output",
)
report = await _call_worker(image_bytes, task.vision_json)
outputs = report["outputs"]
# Every quote or none. A half-measured piece read as a whole one is worse
# than a piece not measured at all.
missing = [q.vision_output for q in quotes if q.vision_output not in outputs]
if missing:
failed = ", ".join(f["tool_name"] for f in report["failures"])
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=(
f"the graph did not produce {', '.join(missing)}"
+ (f" (tools that failed: {failed})" if failed else "")
),
)
result = VisionResult(
task_id=task.id,
engine_version=report["engine_version"],
executed_on="server",
graph_snapshot=task.vision_json,
duration_ms=report.get("duration_ms"),
)
db.add(result)
await db.flush()
saved: list[Measurement] = []
for quote in quotes:
saved.append(await measurement_service.save_measurement(
db,
subtask_id=quote.id,
version_id=version_id,
measured_by=measured_by,
value=float(outputs[quote.vision_output]),
lot_number=lot_number,
serial_number=serial_number,
input_method="camera",
production_run_id=production_run_id,
vision_result_id=result.id,
))
return result, saved