From ee0f65ed3922bb2b7583d83314bfc994d6637361 Mon Sep 17 00:00:00 2001 From: "Adriano (daitarn)" Date: Wed, 7 Oct 2026 12:12:50 +0200 Subject: [PATCH] Endpoint ricetta VisionSuite v2, execute con overlay e immagine salvata (#46) Co-Authored-By: Claude Sonnet 5.5 --- src/backend/api/routers/vision.py | 118 +++++++++- src/backend/models/api/vision.py | 28 +++ src/backend/services/vision_service.py | 213 +++++++++++++++++- src/backend/tests/test_vision_execute.py | 4 +- .../tests/test_vision_execute_gates.py | 2 +- src/backend/tests/test_vision_reference.py | 2 +- .../tests/test_vision_upload_limits.py | 2 +- 7 files changed, 354 insertions(+), 15 deletions(-) diff --git a/src/backend/api/routers/vision.py b/src/backend/api/routers/vision.py index e93c30b..94bc95f 100644 --- a/src/backend/api/routers/vision.py +++ b/src/backend/api/routers/vision.py @@ -5,19 +5,25 @@ the numbers: that is what lets a station's configuration change without touching the frontend or the recipes. """ import io +import json +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, validate_file_size +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 ( - VisionExecuteResponse, VisionMeasurementResult, VisionReferenceImageResponse, + 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 vision_service @@ -126,7 +132,7 @@ async def execute( ) -> VisionExecuteResponse: task = await _task_or_404(db, task_id) - result, saved = await vision_service.execute_task( + result, saved, report = await vision_service.execute_task( db, task, await _read_image(image), @@ -135,6 +141,7 @@ async def execute( 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() @@ -147,6 +154,111 @@ async def execute( ) 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.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, + ) + + +@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) + + # 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 + 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, ) diff --git a/src/backend/models/api/vision.py b/src/backend/models/api/vision.py index 56b58b7..d1d1782 100644 --- a/src/backend/models/api/vision.py +++ b/src/backend/models/api/vision.py @@ -16,6 +16,34 @@ class VisionExecuteResponse(BaseModel): vision_result_id: int engine_version: str measurements: list[VisionMeasurementResult] + # Esito VisionSuite (informativo: il verdetto lo danno utl/ltl), overlay + # in px immagine, URL dell'immagine salvata e strumenti falliti (#46). + outcome: str | None = None + overlay: list[dict] = [] + image_url: str | None = None + failures: list[dict] = [] + + +class VisionRecipeOutput(BaseModel): + """One numeric output a recipe offers to the quotes.""" + key: str + quantity: str | None = None + unit: str | None = None + + +class VisionRecipeUploadResponse(BaseModel): + task_id: int + recipe_dir: str + files: list[str] + outputs: list[VisionRecipeOutput] + + +class VisionRecipeResponse(BaseModel): + task_id: int + recipe_dir: str | None = None + recipe: dict | None = None + files: list[str] + outputs: list[VisionRecipeOutput] class VisionReferenceImageResponse(BaseModel): diff --git a/src/backend/services/vision_service.py b/src/backend/services/vision_service.py index 58b1146..8b74e2d 100644 --- a/src/backend/services/vision_service.py +++ b/src/backend/services/vision_service.py @@ -5,6 +5,7 @@ to the worker over HTTP and stays ignorant of how a number was produced. """ from __future__ import annotations +import hashlib import json import math from pathlib import Path @@ -46,7 +47,13 @@ def _is_measurable(value: object) -> bool: ) -async def _call_worker(image_bytes: bytes, graph: dict) -> dict: +async def _call_worker( + image_bytes: bytes, + graph: dict, + *, + recipe_dir: str | None = None, + calibration: dict | None = None, +) -> dict: """The one place that talks to the worker; the tests replace it. The worker's fault must not become the API's fault (spec's edge-case @@ -60,12 +67,19 @@ async def _call_worker(image_bytes: bytes, graph: dict) -> dict: is not the worker's JSON contract must not turn into a bare KeyError further down. """ + # recipe_dir (relativo, #46) e calibration sono opzionali: se assenti non + # si mandano, il worker usa i suoi default. + data = {"graph": json.dumps(graph)} + if recipe_dir: + data["recipe_dir"] = recipe_dir + if calibration: + data["calibration"] = json.dumps(calibration) try: async with httpx.AsyncClient(timeout=120.0) as client: response = await client.post( f"{settings.vision_worker_url}/run", files={"image": ("frame.png", image_bytes, "image/png")}, - data={"graph": json.dumps(graph)}, + data=data, ) except httpx.TimeoutException as exc: raise HTTPException( @@ -122,6 +136,180 @@ async def _call_worker(image_bytes: bytes, graph: dict) -> dict: return report +async def validate_recipe(graph: dict, recipe_dir: str | None) -> dict: + """Ask the worker's /validate whether a v2 recipe is loadable (#46). + + Returns {"ok", "errors", "outputs"}. A worker 422 (e.g. recipe_dir + refused) is folded into ok=False; transport faults become 502/504 as in + `_call_worker`. Replaced by the tests. + """ + data = {"graph": json.dumps(graph)} + if recipe_dir: + data["recipe_dir"] = recipe_dir + try: + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.post( + f"{settings.vision_worker_url}/validate", data=data, + ) + except httpx.TimeoutException as exc: + raise HTTPException( + status_code=status.HTTP_504_GATEWAY_TIMEOUT, + detail=f"the vision worker did not answer in time: {exc}", + ) from exc + except httpx.HTTPError as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"the vision worker is unreachable: {exc}", + ) from exc + if response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT: + try: + detail = response.json().get("detail", response.text) + except ValueError: + detail = response.text + errors = detail if isinstance(detail, list) else [str(detail)] + return {"ok": False, "errors": [str(e) for e in errors], "outputs": []} + if response.status_code != 200: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"the vision worker refused the job: {response.text}", + ) + try: + body = response.json() + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="the vision worker's answer was malformed: not valid JSON", + ) from exc + if not isinstance(body, dict) or "ok" not in body: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="the vision worker's answer was malformed: missing ok", + ) + body.setdefault("errors", []) + body.setdefault("outputs", []) + return body + + +# Task types that run a vision graph (#46). +VISION_TASK_TYPES = ("camera_measure", "dxf_compare") + + +def _safe_join(root: Path, relative: str) -> Path: + """root/relative, refusing absolute paths, '..' and anything leaving root.""" + candidate = Path(relative) + if candidate.is_absolute() or ".." in candidate.parts or not candidate.parts: + raise ValueError(f"unsafe path: {relative}") + full = (root / candidate).resolve() + if not full.is_relative_to(root.resolve()): + raise ValueError(f"unsafe path: {relative}") + return full + + +def collect_meta_paths(node: object) -> list[str]: + """Every `path` string found in any dict under the recipe's `meta`.""" + found: list[str] = [] + if isinstance(node, dict): + for key, value in node.items(): + if key == "path" and isinstance(value, str): + found.append(value) + else: + found.extend(collect_meta_paths(value)) + elif isinstance(node, list): + for item in node: + found.extend(collect_meta_paths(item)) + return found + + +def match_recipe_files( + recipe: dict, uploads: list[tuple[str, bytes]], +) -> tuple[dict[str, bytes], list[str]]: + """Pair each uploaded file with a meta path by base name (#46). + + Returns ({relative path: bytes}, errors). Errors list every upload that + has no name or matches no meta path, and every unsafe meta path. A meta + path left without a file is not an error here: /validate decides. + """ + paths = collect_meta_paths(recipe.get("meta", {})) + by_name: dict[str, str] = {} + for p in paths: + by_name.setdefault(Path(p.replace("\\", "/")).name, p) + placed: dict[str, bytes] = {} + errors: list[str] = [] + for name, content in uploads: + if not name: + errors.append("a file has no name") + continue + target = by_name.get(Path(name.replace("\\", "/")).name) + if target is None: + errors.append(f"file {name!r} matches no path in the recipe's meta") + continue + try: + _safe_join(Path("."), target) + except ValueError: + errors.append(f"meta path {target!r} is not allowed") + continue + placed[target] = content + return placed, errors + + +def store_recipe_folder( + task_id: int, recipe: dict, files: dict[str, bytes], +) -> str: + """Write the immutable recipe folder; return its path relative to uploads. + + Folder name = first 16 hex of the recipe's sha256. If it already exists it + is left untouched (immutable: other task versions may reference it). + """ + canonical = json.dumps(recipe, sort_keys=True, separators=(",", ":")) + digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16] + relative = f"vision/recipes/{task_id}/{digest}" + folder = settings.upload_path / relative + if folder.exists(): + return relative + folder.mkdir(parents=True, exist_ok=True) + (folder / "recipe.json").write_text( + json.dumps(recipe, indent=2, ensure_ascii=False), encoding="utf-8", + ) + for rel, content in files.items(): + target = _safe_join(folder, rel) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(content) + return relative + + +def list_recipe_files(recipe_dir: str | None) -> list[str]: + """Files of a recipe folder, relative to it (recipe.json excluded).""" + if not recipe_dir: + return [] + try: + folder = _safe_join(settings.upload_path, recipe_dir) + except ValueError: + return [] + if not folder.is_dir(): + return [] + return sorted( + p.relative_to(folder).as_posix() + for p in folder.rglob("*") if p.is_file() and p.name != "recipe.json" + ) + + +def save_result_files( + task_id: int, image_bytes: bytes, overlay: list, image_ext: str = "png", +) -> tuple[str, str]: + """Save the executed image and its overlay JSON side by side (#46). + + Returns (image_path, overlay_path), both relative to uploads. + """ + stem = uuid4().hex + base = Path("vision") / "results" / str(task_id) + (settings.upload_path / base).mkdir(parents=True, exist_ok=True) + image_rel = (base / f"{stem}.{image_ext}").as_posix() + overlay_rel = (base / f"{stem}.overlay.json").as_posix() + (settings.upload_path / image_rel).write_bytes(image_bytes) + (settings.upload_path / overlay_rel).write_text(json.dumps(overlay), encoding="utf-8") + return image_rel, overlay_rel + + async def execute_task( db: AsyncSession, task: RecipeTask, @@ -132,8 +320,9 @@ async def execute_task( lot_number: str | None = None, serial_number: str | None = None, production_run_id: int | None = None, -) -> tuple[VisionResult, list[Measurement]]: - if task.task_type != "camera_measure": + image_ext: str = "png", +) -> tuple[VisionResult, list[Measurement], dict]: + if task.task_type not in VISION_TASK_TYPES: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=f"task type {task.task_type!r} is not a camera measurement", @@ -210,7 +399,11 @@ async def execute_task( ), ) - report = await _call_worker(image_bytes, task.vision_json) + # Nessuna fonte di calibrazione esiste oggi (ne' task ne' stazione): non si + # passa `calibration`, la ricetta porta la sua (#46). + report = await _call_worker( + image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir, + ) outputs = report["outputs"] # Every quote or none. A half-measured piece read as a whole one is worse @@ -231,8 +424,12 @@ async def execute_task( ), ) + overlay = report.get("overlay") or [] + image_path, overlay_path = save_result_files(task.id, image_bytes, overlay, image_ext) result = VisionResult( task_id=task.id, + image_path=image_path, + overlay_path=overlay_path, engine_version=report["engine_version"], executed_on="server", graph_snapshot=task.vision_json, @@ -257,7 +454,7 @@ async def execute_task( enforce_tolerance_gate=False, )) - return result, saved + return result, saved, report def _reference_file_path(reference: VisionReferenceImage) -> Path: @@ -377,4 +574,6 @@ async def preview(db: AsyncSession, task: RecipeTask, image_bytes: bytes) -> dic status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="this task carries no vision graph", ) - return await _call_worker(image_bytes, task.vision_json) + return await _call_worker( + image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir, + ) diff --git a/src/backend/tests/test_vision_execute.py b/src/backend/tests/test_vision_execute.py index e9cff58..2073bf9 100644 --- a/src/backend/tests/test_vision_execute.py +++ b/src/backend/tests/test_vision_execute.py @@ -66,7 +66,7 @@ async def two_vision_quotes(db_session, recipe_id: int): def worker_says(monkeypatch): """The worker does not run in tests: its response is faked here.""" def _install(outputs: dict, failures: list | None = None): - async def fake_call(image_bytes: bytes, graph: dict) -> dict: + async def fake_call(image_bytes: bytes, graph: dict, **kwargs) -> dict: return { "outputs": outputs, "failures": failures or [], @@ -316,7 +316,7 @@ async def test_a_pending_fail_blocks_before_the_worker_is_called( ) await db_session.commit() - async def worker_must_not_be_called(image_bytes: bytes, graph: dict) -> dict: + async def worker_must_not_be_called(image_bytes: bytes, graph: dict, **kwargs) -> dict: raise AssertionError("the worker must not run while the operator is blocked") monkeypatch.setattr( "src.backend.services.vision_service._call_worker", worker_must_not_be_called, diff --git a/src/backend/tests/test_vision_execute_gates.py b/src/backend/tests/test_vision_execute_gates.py index 8d96923..895d9b3 100644 --- a/src/backend/tests/test_vision_execute_gates.py +++ b/src/backend/tests/test_vision_execute_gates.py @@ -53,7 +53,7 @@ async def a_camera_task(db_session, recipe_id: int): @pytest.fixture def worker_says(monkeypatch): def _install(outputs: dict): - async def fake_call(image_bytes: bytes, graph: dict) -> dict: + async def fake_call(image_bytes: bytes, graph: dict, **kwargs) -> dict: return {"outputs": outputs, "failures": [], "engine_version": "e" * 40, "duration_ms": 3} monkeypatch.setattr( diff --git a/src/backend/tests/test_vision_reference.py b/src/backend/tests/test_vision_reference.py index 3b5eeae..8d02fd3 100644 --- a/src/backend/tests/test_vision_reference.py +++ b/src/backend/tests/test_vision_reference.py @@ -39,7 +39,7 @@ async def recipe_parts(db_session, recipe_id: int): @pytest.fixture def worker_says(monkeypatch): def _install(outputs: dict): - async def fake_call(image_bytes: bytes, graph: dict) -> dict: + async def fake_call(image_bytes: bytes, graph: dict, **kwargs) -> dict: return {"outputs": outputs, "failures": [], "engine_version": "b" * 40, "duration_ms": 7} monkeypatch.setattr( diff --git a/src/backend/tests/test_vision_upload_limits.py b/src/backend/tests/test_vision_upload_limits.py index 4718781..34b3c58 100644 --- a/src/backend/tests/test_vision_upload_limits.py +++ b/src/backend/tests/test_vision_upload_limits.py @@ -69,7 +69,7 @@ async def recipe_parts(db_session, recipe_id: int): @pytest.fixture def worker_says(monkeypatch): def _install(outputs: dict): - async def fake_call(image_bytes: bytes, graph: dict) -> dict: + async def fake_call(image_bytes: bytes, graph: dict, **kwargs) -> dict: return {"outputs": outputs, "failures": [], "engine_version": "c" * 40, "duration_ms": 5} monkeypatch.setattr(