"""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 import mimetypes 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) # #105: l'immagine di riferimento resta nella cartella (non e' in meta, quindi # /validate e l'esecuzione la ignorano) e il wizard puo' riaprirla; entra nel digest. placed[f"riferimento.{_IMAGE_EXTENSIONS.get(image.content_type, 'png')}"] = image_bytes 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 @router.get("/tasks/{task_id}/recipe/dxf-compare/source") async def get_dxf_compare_source( task_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(require_maker), ) -> dict: """#105: i parametri della ricetta fatta con «Crea ricetta da DXF», per modificarla. 404 se il task non ha una ricetta del wizard (nessuna, o caricata a mano). """ task = await _task_or_404(db, task_id) source = vision_service.dxf_compare_source(task.vision_json, task.vision_recipe_dir) if source is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="nessuna ricetta da DXF su questo task", ) return source @router.get("/tasks/{task_id}/recipe/files/{path:path}") async def get_recipe_file( task_id: int, path: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(require_maker), ) -> FileResponse: """#105: un file della cartella ricetta del task (DXF, immagine di riferimento). Percorso non sicuro, file assente o task senza ricetta: sempre 404. """ task = await _task_or_404(db, task_id) target = vision_service.recipe_file_path(task.vision_recipe_dir, path) if target is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="file not found") media_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream" return FileResponse(target, media_type=media_type)