ebaafbe6e9
Worker: POST /dxf/preview legge il DXF con read_xld_dxf (lo stesso lettore di CompareDxf) e ritorna contorni in mm con y verso il basso, bbox, dimensioni, entita e punti (tetto 20000, decimazione uniforme). Backend: POST /api/vision/dxf-preview (Maker) che inoltra al worker; 422 inoltrato. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
455 lines
18 KiB
Python
455 lines
18 KiB
Python
"""The client asks for a measurement and gets an outcome.
|
|
|
|
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
|
|
from PIL import Image
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from src.backend.api.middleware.api_key import require_maker, require_measurement_tec
|
|
from src.backend.api.routers.files import (
|
|
ALLOWED_IMAGE_TYPES, resolve_upload_path, validate_file_size,
|
|
)
|
|
from src.backend.config import settings
|
|
from src.backend.database import get_db
|
|
from src.backend.models.api.vision import (
|
|
VisionCheckResponse, VisionExecuteResponse, VisionMeasurementResult, VisionRecipeResponse,
|
|
VisionRecipeUploadResponse, VisionReferenceImageResponse,
|
|
)
|
|
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 station_service, vision_service
|
|
|
|
router = APIRouter(prefix="/api/vision", tags=["vision"])
|
|
|
|
# A vision graph measures pixels, not a PDF: stricter than files.py's
|
|
# ALLOWED_TYPES, which also accepts documents for technical drawings.
|
|
_IMAGE_EXTENSIONS = {
|
|
"image/jpeg": "jpg",
|
|
"image/png": "png",
|
|
"image/gif": "gif",
|
|
"image/webp": "webp",
|
|
"image/bmp": "bmp", # #99
|
|
"image/x-ms-bmp": "bmp", # #99
|
|
}
|
|
|
|
# #99: the Opto bench cameras save BMP and the worker decodes it (PIL), so the
|
|
# vision routes accept it on top of files.py's list. files.py is untouched: its
|
|
# attachments are served by extension and BMP is not meant to be shown there.
|
|
_VISION_IMAGE_TYPES = ALLOWED_IMAGE_TYPES | {"image/bmp", "image/x-ms-bmp"}
|
|
|
|
# Sniffed from the first bytes, independent of the declared Content-Type: a
|
|
# text file uploaded as "image/png" passes the header check above and must
|
|
# still be caught before it reaches the worker as a bare 500 (#15). TIFF is
|
|
# included even though the allow-list does not accept it (PIL, and so the
|
|
# worker, can decode it; browsers do not show it in the result); BMP is now
|
|
# also allowed (#99). This check only refuses what is not a real image at all,
|
|
# it does not narrow the allow-list above.
|
|
_IMAGE_SIGNATURES: tuple[tuple[bytes, ...], ...] = (
|
|
(b"\x89PNG\r\n\x1a\n",), # PNG
|
|
(b"\xff\xd8\xff",), # JPEG
|
|
(b"BM",), # BMP
|
|
(b"II*\x00", b"MM\x00*"), # TIFF, little- and big-endian
|
|
(b"GIF87a", b"GIF89a"), # GIF
|
|
)
|
|
|
|
|
|
def _looks_like_an_image(content: bytes) -> bool:
|
|
"""True if `content` starts with a known image format's magic bytes."""
|
|
for signatures in _IMAGE_SIGNATURES:
|
|
if any(content.startswith(sig) for sig in signatures):
|
|
return True
|
|
# WebP: "RIFF" + 4-byte size + "WEBP", the size in between is not fixed.
|
|
return len(content) >= 12 and content[:4] == b"RIFF" and content[8:12] == b"WEBP"
|
|
|
|
|
|
async def _task_or_404(db: AsyncSession, task_id: int) -> RecipeTask:
|
|
"""Shared by the endpoints of this router."""
|
|
task = (await db.execute(
|
|
select(RecipeTask).where(RecipeTask.id == task_id)
|
|
)).scalar_one_or_none()
|
|
if task is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
|
|
)
|
|
return task
|
|
|
|
|
|
async def _read_image(image: UploadFile) -> bytes:
|
|
"""Type checked from the header before reading, size checked once the body
|
|
is in hand - the same two-step convention `files.py::upload_file` already
|
|
uses, reused rather than reinvented here.
|
|
"""
|
|
if image.content_type not in _VISION_IMAGE_TYPES: # #99
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"File type {image.content_type} not allowed. Must be an image.",
|
|
)
|
|
content = await image.read()
|
|
if not validate_file_size(len(content)):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=(
|
|
f"File size {len(content)} bytes exceeds maximum "
|
|
f"{settings.max_upload_size_mb}MB"
|
|
),
|
|
)
|
|
if not _looks_like_an_image(content):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail="the file's content does not match a supported image format",
|
|
)
|
|
_reject_animated(content)
|
|
return content
|
|
|
|
|
|
def _reject_animated(content: bytes) -> None:
|
|
"""Animated GIF/WebP/APNG have no single frame to measure (decision of
|
|
06/10/2026, #43): refuse them instead of silently reading frame 0."""
|
|
try:
|
|
with Image.open(io.BytesIO(content)) as img:
|
|
animated = getattr(img, "is_animated", False) or getattr(img, "n_frames", 1) > 1
|
|
except Exception:
|
|
# Unreadable here is not this check's business: the magic bytes passed
|
|
# and the worker decides what it can decode.
|
|
return
|
|
if animated:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail="animated images are not supported",
|
|
)
|
|
|
|
|
|
@router.post("/execute", response_model=VisionExecuteResponse)
|
|
async def execute(
|
|
task_id: int = Form(...),
|
|
image: UploadFile = File(...),
|
|
lot_number: str | None = Form(None, max_length=100),
|
|
serial_number: str | None = Form(None, max_length=100),
|
|
production_run_id: int | None = Form(None, gt=0),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(require_measurement_tec),
|
|
) -> VisionExecuteResponse:
|
|
task = await _task_or_404(db, task_id)
|
|
|
|
result, saved, report = await vision_service.execute_task(
|
|
db,
|
|
task,
|
|
await _read_image(image),
|
|
current_user.id,
|
|
version_id=task.version_id,
|
|
lot_number=lot_number,
|
|
serial_number=serial_number,
|
|
production_run_id=production_run_id,
|
|
image_ext=_IMAGE_EXTENSIONS.get(image.content_type, "png"),
|
|
)
|
|
await db.commit()
|
|
|
|
return VisionExecuteResponse(
|
|
vision_result_id=result.id,
|
|
engine_version=result.engine_version,
|
|
measurements=[
|
|
VisionMeasurementResult(
|
|
subtask_id=m.subtask_id, value=float(m.value), pass_fail=m.pass_fail,
|
|
)
|
|
for m in saved
|
|
],
|
|
outcome=report.get("outcome"),
|
|
overlay=report.get("overlay") or [],
|
|
image_url=f"/api/vision/results/{result.id}/image",
|
|
failures=report.get("failures") or [],
|
|
)
|
|
|
|
|
|
@router.post("/check", response_model=VisionCheckResponse)
|
|
async def check(
|
|
task_id: int = Form(...),
|
|
image: UploadFile = File(...),
|
|
production_run_id: int | None = Form(None, gt=0),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(require_measurement_tec),
|
|
) -> VisionCheckResponse:
|
|
"""#97: analisi di un frame senza salvare nulla (ne' risultato ne' misure)."""
|
|
task = await _task_or_404(db, task_id)
|
|
report = await vision_service.check_task(
|
|
db, task, await _read_image(image), production_run_id=production_run_id,
|
|
)
|
|
return VisionCheckResponse(**report)
|
|
|
|
|
|
@router.get("/results/{result_id}/image")
|
|
async def get_result_image(
|
|
result_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(require_measurement_tec),
|
|
) -> FileResponse:
|
|
"""The image a vision run measured, as saved by `execute` (#46)."""
|
|
result = (await db.execute(
|
|
select(VisionResult).where(VisionResult.id == result_id)
|
|
)).scalar_one_or_none()
|
|
if result is None or not result.image_path:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Result image not found",
|
|
)
|
|
return FileResponse(resolve_upload_path(result.image_path))
|
|
|
|
|
|
def _422(errors: list[str]) -> HTTPException:
|
|
# detail e' una lista di stringhe: il frontend legge i 422 solo da `detail`.
|
|
return HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=errors,
|
|
)
|
|
|
|
|
|
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,
|
|
recipe: UploadFile = File(...),
|
|
files: list[UploadFile] = File(default=[]),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(require_maker),
|
|
) -> VisionRecipeUploadResponse:
|
|
"""Load a VisionSuite v2 recipe plus its meta files onto a task (#46)."""
|
|
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"])
|
|
|
|
raw = await recipe.read()
|
|
if not validate_file_size(len(raw)):
|
|
raise _422([f"recipe exceeds {settings.max_upload_size_mb}MB"])
|
|
try:
|
|
graph = json.loads(raw)
|
|
except ValueError:
|
|
raise _422(["the recipe is not valid JSON"])
|
|
if not isinstance(graph, dict):
|
|
raise _422(["the recipe must be a JSON object"])
|
|
|
|
uploads: list[tuple[str, bytes]] = []
|
|
for f in files:
|
|
content = await f.read()
|
|
if not validate_file_size(len(content)):
|
|
raise _422([f"file {f.filename!r} exceeds {settings.max_upload_size_mb}MB"])
|
|
uploads.append((f.filename or "", content))
|
|
placed, errors = vision_service.match_recipe_files(graph, uploads)
|
|
if errors:
|
|
raise _422(errors)
|
|
|
|
recipe_dir, check = await _store_validate_assign(task, graph, placed)
|
|
await db.commit()
|
|
return VisionRecipeUploadResponse(
|
|
task_id=task.id,
|
|
recipe_dir=recipe_dir,
|
|
files=vision_service.list_recipe_files(recipe_dir),
|
|
outputs=check["outputs"],
|
|
)
|
|
|
|
|
|
@router.get("/tasks/{task_id}/recipe", response_model=VisionRecipeResponse)
|
|
async def get_recipe(
|
|
task_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(require_maker),
|
|
) -> VisionRecipeResponse:
|
|
task = await _task_or_404(db, task_id)
|
|
outputs: list[dict] = []
|
|
if task.vision_json:
|
|
check = await vision_service.validate_recipe(
|
|
task.vision_json, task.vision_recipe_dir,
|
|
)
|
|
outputs = check.get("outputs", [])
|
|
return VisionRecipeResponse(
|
|
task_id=task.id,
|
|
recipe_dir=task.vision_recipe_dir,
|
|
recipe=task.vision_json,
|
|
files=vision_service.list_recipe_files(task.vision_recipe_dir),
|
|
outputs=outputs,
|
|
)
|
|
|
|
|
|
# Composing a recipe and keeping a reference image are Maker operations, not
|
|
# measuring: every endpoint below gates on `require_maker`, not the
|
|
# `require_measurement_tec` that `execute` above uses.
|
|
|
|
|
|
@router.post("/reference-images")
|
|
async def add_reference_image(
|
|
task_id: int = Form(...),
|
|
image: UploadFile = File(...),
|
|
note: str | None = Form(None),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(require_maker),
|
|
) -> dict:
|
|
task = await _task_or_404(db, task_id)
|
|
content = await _read_image(image)
|
|
reference = await vision_service.save_reference_image(
|
|
db, task.id, content, note=note,
|
|
extension=_IMAGE_EXTENSIONS[image.content_type],
|
|
)
|
|
try:
|
|
await db.commit()
|
|
except Exception:
|
|
# The row never became durable - the file `save_reference_image`
|
|
# wrote after its flush would otherwise outlive it as an orphan.
|
|
vision_service.remove_reference_file(reference)
|
|
raise
|
|
return {"id": reference.id, "path": reference.path, "note": reference.note}
|
|
|
|
|
|
@router.get("/reference-images", response_model=list[VisionReferenceImageResponse])
|
|
async def get_reference_images(
|
|
task_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(require_maker),
|
|
) -> list[VisionReferenceImageResponse]:
|
|
await _task_or_404(db, task_id)
|
|
return await vision_service.list_reference_images(db, task_id)
|
|
|
|
|
|
@router.delete("/reference-images/{reference_id}", status_code=204)
|
|
async def delete_reference_image(
|
|
reference_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(require_maker),
|
|
) -> None:
|
|
reference = await vision_service.delete_reference_image(db, reference_id)
|
|
await db.commit()
|
|
# Only now: if the commit above had failed, the row would still exist and
|
|
# the file must still be there for it. And only if no other row (a
|
|
# copy-on-write sibling, #38.6) still references the same path.
|
|
if not await vision_service.file_still_referenced(db, reference.path):
|
|
vision_service.remove_reference_file(reference)
|
|
|
|
|
|
@router.post("/preview")
|
|
async def preview(
|
|
task_id: int = Form(...),
|
|
image: UploadFile = File(...),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(require_maker),
|
|
) -> dict:
|
|
task = await _task_or_404(db, task_id)
|
|
return await vision_service.preview(db, task, await _read_image(image))
|
|
|
|
|
|
@router.post("/dxf-preview")
|
|
async def dxf_preview(
|
|
dxf: UploadFile = File(...),
|
|
current_user: User = Depends(require_maker),
|
|
) -> dict:
|
|
"""#101: anteprima del DXF scelto nel wizard «Crea ricetta da DXF» (contorni
|
|
in mm e dimensioni), letto dal worker con lo stesso lettore del confronto.
|
|
Non scrive nulla; stesso limite di dimensione della rotta dxf-compare."""
|
|
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"])
|
|
return await vision_service.dxf_preview(dxf_bytes)
|
|
|
|
|
|
@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
|