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:
2026-10-23 22:44:15 +02:00
parent ae2353f7f8
commit ad6ce5d11d
3 changed files with 624 additions and 16 deletions
+103 -14
View File
@@ -4,8 +4,11 @@ It does not know, and must not know, whether the server or the station computed
the numbers: that is what lets a station's configuration change without touching
the frontend or the recipes.
"""
import base64
import binascii
import io
import json
from typing import Literal
from fastapi.responses import FileResponse
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
@@ -26,7 +29,7 @@ from src.backend.models.api.vision import (
from src.backend.models.orm.vision import VisionResult
from src.backend.models.orm.task import RecipeTask
from src.backend.models.orm.user import User
from src.backend.services import vision_service
from src.backend.services import station_service, vision_service
router = APIRouter(prefix="/api/vision", tags=["vision"])
@@ -185,6 +188,30 @@ def _422(errors: list[str]) -> HTTPException:
)
async def _store_validate_assign(
task: RecipeTask, graph: dict, placed: dict[str, bytes],
) -> tuple[str, dict]:
"""Scrive la cartella ricetta, la fa validare dal worker, la assegna al task.
Condiviso da upload_recipe e dxf-compare con save (#83): stesso
comportamento, un solo posto. Non committa. Ritorna (recipe_dir, check).
"""
# The folder is content-addressed and immutable; writing it before the
# validation is harmless (an orphan folder is never referenced) and the
# worker needs it on disk to validate meta files.
try:
recipe_dir = vision_service.store_recipe_folder(task.id, graph, placed)
except ValueError as exc:
raise _422([str(exc)])
check = await vision_service.validate_recipe(graph, recipe_dir)
if not check["ok"]:
raise _422([str(e) for e in check["errors"]] or ["the recipe is not valid"])
task.vision_json = graph
task.vision_recipe_dir = recipe_dir
return recipe_dir, check
@router.post("/tasks/{task_id}/recipe", response_model=VisionRecipeUploadResponse)
async def upload_recipe(
task_id: int,
@@ -218,19 +245,7 @@ async def upload_recipe(
if errors:
raise _422(errors)
# The folder is content-addressed and immutable; writing it before the
# validation is harmless (an orphan folder is never referenced) and the
# worker needs it on disk to validate meta files.
try:
recipe_dir = vision_service.store_recipe_folder(task.id, graph, placed)
except ValueError as exc:
raise _422([str(exc)])
check = await vision_service.validate_recipe(graph, recipe_dir)
if not check["ok"]:
raise _422([str(e) for e in check["errors"]] or ["the recipe is not valid"])
task.vision_json = graph
task.vision_recipe_dir = recipe_dir
recipe_dir, check = await _store_validate_assign(task, graph, placed)
await db.commit()
return VisionRecipeUploadResponse(
task_id=task.id,
@@ -325,3 +340,77 @@ async def preview(
) -> dict:
task = await _task_or_404(db, task_id)
return await vision_service.preview(db, task, await _read_image(image))
@router.post("/tasks/{task_id}/recipe/dxf-compare")
async def create_dxf_compare_recipe(
task_id: int,
image: UploadFile = File(...),
dxf: UploadFile = File(...),
station_id: int | None = Form(None, gt=0),
px_per_mm: float | None = Form(None, gt=0),
band_mm: float = Form(0.1, gt=0),
min_points_pct: float = Form(95, ge=0, le=100),
polarity: Literal["dark", "light"] = Form("dark"),
save: bool = Form(False),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_maker),
) -> dict:
"""Ricetta DXF compare da un'immagine e un DXF, senza Studio (#83).
La scala e' `px_per_mm`, o quella della stazione (letta dall'acquisitore).
Con save=false si vede il risultato e non si scrive nulla; con save=true la
ricetta va sul task come con l'upload, e se il task non ha nessuna quota di
visione nasce la quota «Punti in tolleranza».
"""
task = await _task_or_404(db, task_id)
if task.task_type not in vision_service.VISION_TASK_TYPES:
raise _422([f"task type {task.task_type!r} does not run a vision recipe"])
station = None
if station_id is not None:
station = await station_service.get_station(db, station_id) # 404 se non esiste
if px_per_mm is None:
calibration, reason = await vision_service.fetch_station_calibration(station)
if calibration is None:
# irraggiungibile e non calibrata sono due guasti diversi per chi lo legge
raise _422([
"acquisitore della stazione non raggiungibile"
if reason == vision_service.CAL_UNREACHABLE else "stazione non calibrata"
])
px_per_mm = float(calibration["px_per_mm"])
image_bytes = await _read_image(image)
dxf_bytes = await dxf.read()
if not validate_file_size(len(dxf_bytes)):
raise _422([f"the DXF exceeds {settings.max_upload_size_mb}MB"])
body = await vision_service.dxf_compare_recipe(
image_bytes, dxf_bytes,
px_per_mm=px_per_mm, band_mm=band_mm, min_points_pct=min_points_pct,
polarity=polarity,
worker_url=(station.vision_worker_url or None) if station is not None else None,
)
# i file stanno nella ricetta salvata, non nella risposta al client
files = body.pop("files")
response = {"result": body, "saved": False, "recipe_dir": None, "outputs": None}
if not save:
return response
uploads: list[tuple[str, bytes]] = []
for name, encoded in files.items():
try:
uploads.append((name, base64.b64decode(encoded, validate=True)))
except (binascii.Error, ValueError, TypeError):
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"the vision worker's answer was malformed: file {name!r} is not base64",
)
placed, errors = vision_service.match_recipe_files(body["graph"], uploads)
if errors:
raise _422(errors)
recipe_dir, check = await _store_validate_assign(task, body["graph"], placed)
await vision_service.ensure_points_quote(db, task, min_points_pct)
await db.commit()
response.update(saved=True, recipe_dir=recipe_dir, outputs=check["outputs"])
return response