"""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 io 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, 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, VisionReferenceImageResponse, ) 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", ) _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 = 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: 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))