ddf7788d77
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
108 lines
3.6 KiB
Python
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
|