Backend: ricetta DXF compare da camera e calibrazione letta dall'acquisitore (#83)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
@@ -24,6 +25,8 @@ from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||
from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
|
||||
from src.backend.services import measurement_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Worker responses whose status and reason are forwarded as-is rather than
|
||||
# flattened into a generic 502 - see `_call_worker`.
|
||||
_FORWARDED_WORKER_STATUSES = frozenset({
|
||||
@@ -140,6 +143,136 @@ async def _call_worker(
|
||||
return report
|
||||
|
||||
|
||||
async def call_worker_multipart(
|
||||
path: str,
|
||||
*,
|
||||
files: dict,
|
||||
data: dict,
|
||||
worker_url: str | None = None,
|
||||
timeout: float = 120.0,
|
||||
) -> dict:
|
||||
"""POST multipart generico al worker (#83): /recipes/dxf-compare.
|
||||
|
||||
Stessa traduzione degli errori di `_call_worker`: 504 sul timeout, 502 sul
|
||||
trasporto o su una risposta non JSON-oggetto; 422/503/504 del worker si
|
||||
inoltrano cosi' come sono. Worker della stazione o, se manca, il centrale.
|
||||
"""
|
||||
base = (worker_url or settings.vision_worker_url).rstrip("/")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
response = await client.post(f"{base}{path}", files=files, data=data)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
||||
detail=f"the vision worker did not answer in time: {exc} ({base})",
|
||||
) from exc
|
||||
except (httpx.TransportError, httpx.HTTPError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"the vision worker is unreachable: {exc} ({base})",
|
||||
) from exc
|
||||
|
||||
if response.status_code in _FORWARDED_WORKER_STATUSES:
|
||||
try:
|
||||
detail = response.json().get("detail", response.text)
|
||||
except ValueError:
|
||||
detail = response.text
|
||||
raise HTTPException(status_code=response.status_code, detail=detail)
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"the vision worker refused the job: {response.text}",
|
||||
)
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"the vision worker's answer was malformed: not valid JSON ({exc})",
|
||||
) from exc
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="the vision worker's answer was malformed: expected a JSON object",
|
||||
)
|
||||
return body
|
||||
|
||||
|
||||
async def dxf_compare_recipe(
|
||||
image_bytes: bytes, dxf_bytes: bytes, *, px_per_mm: float, band_mm: float,
|
||||
min_points_pct: float, polarity: str, worker_url: str | None = None,
|
||||
) -> dict:
|
||||
"""W2: ricetta dxf-compare da immagine + DXF (#83). Sostituita dai test."""
|
||||
body = await call_worker_multipart(
|
||||
"/recipes/dxf-compare",
|
||||
files={
|
||||
"image": ("frame.png", image_bytes, "image/png"),
|
||||
"dxf": ("disegno.dxf", dxf_bytes, "application/octet-stream"),
|
||||
},
|
||||
data={
|
||||
"px_per_mm": str(px_per_mm), "band_mm": str(band_mm),
|
||||
"min_points_pct": str(min_points_pct), "polarity": polarity,
|
||||
},
|
||||
worker_url=worker_url,
|
||||
)
|
||||
# forma minima del contratto W2: senza graph e files non c'e' nulla da salvare
|
||||
if not isinstance(body.get("graph"), dict) or not isinstance(body.get("files"), dict):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="the vision worker's answer was malformed: missing graph or files",
|
||||
)
|
||||
return body
|
||||
|
||||
|
||||
# Stati di fetch_station_calibration (#83)
|
||||
CAL_OK = "ok"
|
||||
CAL_NO_CAMERA = "no_camera" # la stazione non ha camera_url/camera_source
|
||||
CAL_NOT_CALIBRATED = "not_calibrated" # l'acquisitore risponde 404 (o un valore inutilizzabile)
|
||||
CAL_UNREACHABLE = "unreachable" # l'acquisitore non risponde
|
||||
|
||||
|
||||
async def fetch_station_calibration(station: Station | None) -> tuple[dict | None, str]:
|
||||
"""Legge la calibrazione dall'acquisitore della stazione (C1, #83).
|
||||
|
||||
GET {camera_url}/camera/calibration?source={camera_source}. La calibrazione
|
||||
vive nell'acquisitore: TMF la legge, non la tiene. Ritorna (dict, "ok")
|
||||
oppure (None, motivo) per distinguere «non calibrata» da «irraggiungibile».
|
||||
Un px_per_mm non finito o <= 0 conta come non calibrata.
|
||||
"""
|
||||
if station is None or not station.camera_url or not station.camera_source:
|
||||
return None, CAL_NO_CAMERA
|
||||
url = f"{station.camera_url.rstrip('/')}/camera/calibration"
|
||||
try:
|
||||
# timeout breve: e' una lettura di servizio, non deve bloccare l'execute
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.get(url, params={"source": station.camera_source})
|
||||
except httpx.HTTPError as exc:
|
||||
logger.warning("acquisitore della stazione %s non raggiungibile: %s", station.code, exc)
|
||||
return None, CAL_UNREACHABLE
|
||||
if response.status_code == status.HTTP_404_NOT_FOUND:
|
||||
return None, CAL_NOT_CALIBRATED
|
||||
if response.status_code != 200:
|
||||
logger.warning(
|
||||
"acquisitore della stazione %s: HTTP %s", station.code, response.status_code,
|
||||
)
|
||||
return None, CAL_UNREACHABLE
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError:
|
||||
logger.warning("acquisitore della stazione %s: risposta non JSON", station.code)
|
||||
return None, CAL_UNREACHABLE
|
||||
px_per_mm = body.get("px_per_mm") if isinstance(body, dict) else None
|
||||
if not (_is_measurable(px_per_mm) and px_per_mm > 0):
|
||||
return None, CAL_NOT_CALIBRATED
|
||||
return body, CAL_OK
|
||||
|
||||
|
||||
async def get_station_calibration(station: Station | None) -> dict | None:
|
||||
"""La calibrazione C1 della stazione, o None in ogni altro caso (#83)."""
|
||||
calibration, _ = await fetch_station_calibration(station)
|
||||
return calibration
|
||||
|
||||
|
||||
async def validate_recipe(graph: dict, recipe_dir: str | None) -> dict:
|
||||
"""Ask the worker's /validate whether a v2 recipe is loadable (#46).
|
||||
|
||||
@@ -365,11 +498,14 @@ async def execute_task(
|
||||
)
|
||||
# Worker della stazione della sessione di produzione (#45); senza run o senza URL: centrale
|
||||
station_url = None
|
||||
# Calibrazione letta dall'acquisitore della stazione (#83); None = non c'e'
|
||||
calibration = None
|
||||
if run is not None:
|
||||
station = (await db.execute(
|
||||
select(Station).where(Station.id == run.station_id)
|
||||
)).scalar_one_or_none()
|
||||
station_url = (station.vision_worker_url or None) if station is not None else None
|
||||
calibration = await get_station_calibration(station)
|
||||
|
||||
if not task.vision_json:
|
||||
raise HTTPException(
|
||||
@@ -419,11 +555,15 @@ async def execute_task(
|
||||
),
|
||||
)
|
||||
|
||||
# Nessuna fonte di calibrazione esiste oggi (ne' task ne' stazione): non si
|
||||
# passa `calibration`, la ricetta porta la sua (#46).
|
||||
# La calibrazione viene dall'acquisitore della stazione (#83); se non c'e',
|
||||
# non si passa `calibration` e la ricetta porta la sua (#46).
|
||||
worker_kwargs: dict = {}
|
||||
if calibration is not None:
|
||||
worker_kwargs["calibration"] = {"px_per_mm": float(calibration["px_per_mm"])}
|
||||
report = await _call_worker(
|
||||
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
|
||||
worker_url=station_url, # #45
|
||||
**worker_kwargs,
|
||||
)
|
||||
outputs = report["outputs"]
|
||||
|
||||
@@ -457,6 +597,8 @@ async def execute_task(
|
||||
station_id=run.station_id if run is not None else None,
|
||||
worker_url=(station_url or settings.vision_worker_url).rstrip("/"),
|
||||
graph_snapshot=task.vision_json,
|
||||
# copiata come il grafo (#83): ricalibrare dopo non cambia cio' che e' stato misurato
|
||||
calibration_snapshot=calibration,
|
||||
duration_ms=report.get("duration_ms"),
|
||||
)
|
||||
db.add(result)
|
||||
@@ -601,3 +743,42 @@ async def preview(db: AsyncSession, task: RecipeTask, image_bytes: bytes) -> dic
|
||||
return await _call_worker(
|
||||
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
|
||||
)
|
||||
|
||||
|
||||
async def ensure_points_quote(
|
||||
db: AsyncSession, task: RecipeTask, min_points_pct: float,
|
||||
) -> bool:
|
||||
"""Crea la quota «Punti in tolleranza» se il task non ha nessuna quota di visione (#83).
|
||||
|
||||
Una sola volta: se esiste gia' una quota con `vision_output`, non si tocca.
|
||||
Se la versione ha gia' misure non si scrive (il copy-on-write sta nel router
|
||||
dei task, qui non si aggira): ritorna False. Ritorna True se l'ha creata.
|
||||
"""
|
||||
from src.backend.services import recipe_service # import locale: evita cicli
|
||||
|
||||
existing = (await db.execute(
|
||||
select(RecipeSubtask.id)
|
||||
.where(RecipeSubtask.task_id == task.id)
|
||||
.where(RecipeSubtask.vision_output.is_not(None))
|
||||
.limit(1)
|
||||
)).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
return False
|
||||
if await recipe_service.version_has_measurements(db, task.version_id):
|
||||
return False
|
||||
markers = (await db.execute(
|
||||
select(RecipeSubtask.marker_number).where(RecipeSubtask.task_id == task.id)
|
||||
)).scalars().all()
|
||||
db.add(RecipeSubtask(
|
||||
task_id=task.id,
|
||||
marker_number=max(markers, default=0) + 1, # unico per task (uq_task_marker)
|
||||
description="Punti in tolleranza",
|
||||
nominal=100,
|
||||
# limiti assoluti come nel resto delle quote: passa se punti% >= min_points_pct
|
||||
ltl=min_points_pct,
|
||||
utl=100,
|
||||
unit="%",
|
||||
vision_output="dxf.points_in_pct",
|
||||
))
|
||||
await db.flush()
|
||||
return True
|
||||
|
||||
Reference in New Issue
Block a user