9afe1e712b
Worker: Image.open().convert() in try/except (422 "image is not decodable"); il grafo deve essere un dict (422 altrimenti); run_graph distingue costruzione (GraphBuildError -> 422) da esecuzione (500 con motivo). Router: verifica dei magic bytes (PNG/JPEG/BMP/TIFF/WebP/GIF) prima di chiamare il worker. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
162 lines
5.9 KiB
Python
162 lines
5.9 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.
|
|
"""
|
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
|
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, validate_file_size
|
|
from src.backend.config import settings
|
|
from src.backend.database import get_db
|
|
from src.backend.models.api.vision import VisionExecuteResponse, VisionMeasurementResult
|
|
from src.backend.models.orm.task import RecipeTask
|
|
from src.backend.models.orm.user import User
|
|
from src.backend.services import 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",
|
|
}
|
|
|
|
# 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). BMP and
|
|
# TIFF are included even though `_IMAGE_EXTENSIONS` does not accept them,
|
|
# because PIL - and so the worker - can decode both; 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 ALLOWED_IMAGE_TYPES:
|
|
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",
|
|
)
|
|
return content
|
|
|
|
|
|
@router.post("/execute", response_model=VisionExecuteResponse)
|
|
async def execute(
|
|
task_id: int = Form(...),
|
|
image: UploadFile = File(...),
|
|
lot_number: str | None = Form(None),
|
|
serial_number: str | None = Form(None),
|
|
production_run_id: int | None = Form(None),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(require_measurement_tec),
|
|
) -> VisionExecuteResponse:
|
|
task = await _task_or_404(db, task_id)
|
|
|
|
result, saved = 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,
|
|
)
|
|
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
|
|
],
|
|
)
|
|
|
|
|
|
# Composing a recipe and keeping a reference image are Maker operations, not
|
|
# measuring: both endpoints below gate 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],
|
|
)
|
|
await db.commit()
|
|
return {"id": reference.id, "path": reference.path, "note": reference.note}
|
|
|
|
|
|
@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))
|