9f2d624b4f
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
291 lines
11 KiB
Python
291 lines
11 KiB
Python
"""The heavy container: the runner behind an internal API.
|
|
|
|
Separate from the FastAPI server on purpose. The API image stays light, a
|
|
VisionSuite upgrade does not restart production traffic, and an execution that
|
|
crashes does not take the other tablets' requests down with it.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import io
|
|
import json
|
|
import os
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
from fastapi import FastAPI, File, Form, HTTPException, UploadFile, status
|
|
from PIL import Image, UnidentifiedImageError
|
|
|
|
from src.vision.costruttori import CostruttoreError, build_dxf_compare # #83
|
|
from src.vision.runner import (
|
|
GraphBuildError,
|
|
RunOutcome,
|
|
engine_version,
|
|
run_graph,
|
|
validate_graph,
|
|
)
|
|
|
|
app = FastAPI(title="TieMeasureFlow Vision Worker", version="0.1.0")
|
|
|
|
# Below the backend's own 120s budget on purpose (#34): the worker must give
|
|
# up and answer 504 before the backend's httpx client times out on its own,
|
|
# so the operator sees a reason instead of a bare timeout.
|
|
_RUN_TIMEOUT_S = float(os.environ.get("VISION_RUN_TIMEOUT_S", "100"))
|
|
# Two slow graphs at once must not also stall a third tablet's request past
|
|
# any reasonable wait - reject it instead, with a status the backend can turn
|
|
# into a 503 the operator understands ("try again"), not a queued request
|
|
# that quietly outlasts anyone's patience.
|
|
_MAX_CONCURRENCY = int(os.environ.get("VISION_MAX_CONCURRENCY", "2"))
|
|
_run_slots = asyncio.Semaphore(_MAX_CONCURRENCY)
|
|
|
|
|
|
# #46: radice sotto cui vivono le cartelle delle ricette (volume uploads,
|
|
# montato in sola lettura nel worker). `recipe_dir` e' relativo a questa radice.
|
|
_RECIPES_ROOT_DEFAULT = "/app/uploads"
|
|
|
|
|
|
def _unprocessable(detail: str) -> HTTPException:
|
|
return HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=detail
|
|
)
|
|
|
|
|
|
def _resolve_recipe_dir(recipe_dir: str | None) -> Path | None:
|
|
"""Percorso RELATIVO sotto VISION_RECIPES_ROOT -> cartella assoluta.
|
|
|
|
Assoluto, `..` o fuori radice (anche via symlink) -> 422. Vuoto -> None.
|
|
"""
|
|
if recipe_dir is None or not recipe_dir.strip():
|
|
return None
|
|
rel = recipe_dir.strip()
|
|
# Letto a ogni richiesta (non a import) cosi' i test possono cambiarlo.
|
|
root = Path(os.environ.get("VISION_RECIPES_ROOT", _RECIPES_ROOT_DEFAULT)).resolve()
|
|
if (
|
|
Path(rel).is_absolute()
|
|
# #46: lettera di unita (C:/x) rifiutata su ogni OS; UNC coperto da startswith("\\")
|
|
or re.match(r"^[A-Za-z]:", rel)
|
|
or rel.startswith(("/", "\\"))
|
|
or ".." in Path(rel.replace("\\", "/")).parts
|
|
):
|
|
raise _unprocessable("recipe_dir must be a relative path under the recipes root")
|
|
resolved = (root / rel).resolve()
|
|
if resolved != root and root not in resolved.parents:
|
|
raise _unprocessable("recipe_dir is outside the recipes root")
|
|
return resolved
|
|
|
|
|
|
def _parse_graph(graph: str) -> dict:
|
|
try:
|
|
parsed = json.loads(graph)
|
|
except json.JSONDecodeError as exc:
|
|
raise _unprocessable(f"graph is not valid JSON: {exc}") from exc
|
|
if not isinstance(parsed, dict):
|
|
raise _unprocessable(
|
|
f"graph must be a JSON object, got {type(parsed).__name__}"
|
|
)
|
|
return parsed
|
|
|
|
|
|
def _parse_calibration(calibration: str | None) -> dict | None:
|
|
if calibration is None or not calibration.strip():
|
|
return None
|
|
try:
|
|
parsed = json.loads(calibration)
|
|
except json.JSONDecodeError as exc:
|
|
raise _unprocessable(f"calibration is not valid JSON: {exc}") from exc
|
|
if not isinstance(parsed, dict) or not (
|
|
parsed.get("mm_per_px") or parsed.get("px_per_mm")
|
|
):
|
|
raise _unprocessable(
|
|
"calibration must be {'mm_per_px': float} or {'px_per_mm': float}"
|
|
)
|
|
return parsed
|
|
|
|
|
|
class _ImageDecodeError(Exception):
|
|
"""The upload is not a decodable image - raised inside the worker thread
|
|
so it survives the trip through `asyncio.to_thread`."""
|
|
|
|
|
|
@app.get("/health")
|
|
async def health() -> dict:
|
|
"""Deliberately just this: reading `engine_version()` (cached after the
|
|
first call) is the only work here, so this answers even while `/run` is
|
|
busy decoding an image or running a graph in its own thread."""
|
|
return {"status": "ok", "engine_version": engine_version()}
|
|
|
|
|
|
def _decode_and_run(
|
|
content: bytes,
|
|
graph: dict,
|
|
calibration: dict | None = None,
|
|
recipe_dir: Path | None = None,
|
|
) -> RunOutcome:
|
|
"""The synchronous, CPU/IO-bound half of a request: PIL decode plus
|
|
`run_graph`. Runs in a worker thread (see `run` below) so the event loop -
|
|
and therefore `/health` - stays responsive while it executes. Once this
|
|
thread is past its budget it is not interruptible: it keeps computing to
|
|
completion in the background even after `run` has already answered 504
|
|
for it (see the timeout handling there).
|
|
"""
|
|
try:
|
|
frame = np.array(Image.open(io.BytesIO(content)).convert("L"))
|
|
except (UnidentifiedImageError, OSError) as exc:
|
|
raise _ImageDecodeError(str(exc)) from exc
|
|
return run_graph(frame, graph, calibration=calibration, recipe_dir=recipe_dir)
|
|
|
|
|
|
@app.post("/run")
|
|
async def run(
|
|
image: UploadFile = File(...),
|
|
graph: str = Form(...),
|
|
recipe_dir: str | None = Form(None),
|
|
calibration: str | None = Form(None),
|
|
) -> dict:
|
|
parsed_graph = _parse_graph(graph)
|
|
resolved_dir = _resolve_recipe_dir(recipe_dir)
|
|
parsed_calibration = _parse_calibration(calibration)
|
|
|
|
content = await image.read()
|
|
|
|
if _run_slots.locked():
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail=f"worker busy: already running {_MAX_CONCURRENCY} job(s)",
|
|
)
|
|
|
|
async with _run_slots:
|
|
try:
|
|
outcome = await asyncio.wait_for(
|
|
asyncio.to_thread(
|
|
_decode_and_run,
|
|
content,
|
|
parsed_graph,
|
|
parsed_calibration,
|
|
resolved_dir,
|
|
),
|
|
timeout=_RUN_TIMEOUT_S,
|
|
)
|
|
except asyncio.TimeoutError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
|
detail=f"vision run exceeded {_RUN_TIMEOUT_S}s",
|
|
) from exc
|
|
except _ImageDecodeError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail=f"image is not decodable: {exc}",
|
|
) from exc
|
|
except GraphBuildError as exc:
|
|
# The graph itself is bad (schema version, a tool missing
|
|
# "kind", ...) - a bad request, not a server fault.
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail=str(exc),
|
|
) from exc
|
|
except Exception as exc:
|
|
# The graph built fine; something failed while actually running
|
|
# it - an engine fault, not the caller's. Still named, not a
|
|
# naked 500.
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"vision run failed: {type(exc).__name__}: {exc}",
|
|
) from exc
|
|
|
|
return {
|
|
# `outcome.outputs` never carries NaN/inf/bool (see runner.run_graph):
|
|
# a JSON encoder that accepts non-finite floats (Python's stdlib json
|
|
# does, by default) must never get the chance to emit them here.
|
|
"outputs": outcome.outputs,
|
|
"failures": [
|
|
{"tool_id": f.tool_id, "tool_name": f.tool_name, "error": f.error}
|
|
for f in outcome.failures
|
|
],
|
|
"engine_version": outcome.engine_version,
|
|
"duration_ms": outcome.duration_ms,
|
|
"invalid_outputs": outcome.invalid_outputs,
|
|
# #46: contorni per l'overlay sull'immagine e esito VisionSuite.
|
|
"overlay": outcome.overlay,
|
|
"outcome": outcome.outcome,
|
|
}
|
|
|
|
|
|
@app.post("/validate")
|
|
async def validate(
|
|
graph: str = Form(...),
|
|
recipe_dir: str | None = Form(None),
|
|
) -> dict:
|
|
"""Validates a v2 recipe (not a draft: a missing meta file is an error)
|
|
and lists its numeric outputs. No image, so it is cheap: no run slot."""
|
|
parsed_graph = _parse_graph(graph)
|
|
resolved_dir = _resolve_recipe_dir(recipe_dir)
|
|
ok, errors, outputs = await asyncio.to_thread(
|
|
validate_graph, parsed_graph, resolved_dir
|
|
)
|
|
return {"ok": ok, "errors": errors, "outputs": outputs}
|
|
|
|
|
|
def _decode_and_build_dxf(content: bytes, dxf: bytes, px_per_mm: float,
|
|
band_mm: float, min_points_pct: float,
|
|
polarity: str) -> dict:
|
|
"""#83: meta' sincrona di /recipes/dxf-compare (decodifica + costruzione),
|
|
in un thread come `_decode_and_run`."""
|
|
try:
|
|
frame = np.array(Image.open(io.BytesIO(content)).convert("L"))
|
|
except (UnidentifiedImageError, OSError) as exc:
|
|
raise _ImageDecodeError(str(exc)) from exc
|
|
return build_dxf_compare(frame, dxf, px_per_mm, band_mm, min_points_pct, polarity)
|
|
|
|
|
|
@app.post("/recipes/dxf-compare")
|
|
async def recipes_dxf_compare(
|
|
image: UploadFile = File(...),
|
|
dxf: UploadFile = File(...),
|
|
px_per_mm: float = Form(...),
|
|
band_mm: float = Form(0.1),
|
|
min_points_pct: float = Form(95.0),
|
|
polarity: str = Form("dark"),
|
|
) -> dict:
|
|
"""#83: costruisce una ricetta DXF compare dall'immagine di riferimento, il
|
|
disegno e la scala. Stessi semaforo, timeout e mappa errori di /run."""
|
|
content = await image.read()
|
|
dxf_bytes = await dxf.read()
|
|
|
|
if _run_slots.locked():
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail=f"worker busy: already running {_MAX_CONCURRENCY} job(s)",
|
|
)
|
|
|
|
async with _run_slots:
|
|
try:
|
|
return await asyncio.wait_for(
|
|
asyncio.to_thread(
|
|
_decode_and_build_dxf, content, dxf_bytes, px_per_mm,
|
|
band_mm, min_points_pct, polarity,
|
|
),
|
|
timeout=_RUN_TIMEOUT_S,
|
|
)
|
|
except asyncio.TimeoutError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
|
detail=f"vision run exceeded {_RUN_TIMEOUT_S}s",
|
|
) from exc
|
|
except _ImageDecodeError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail=f"image is not decodable: {exc}",
|
|
) from exc
|
|
except (CostruttoreError, GraphBuildError) as exc:
|
|
# pezzo non trovato, DXF illeggibile, parametri invalidi: input, non guasto
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail=str(exc),
|
|
) from exc
|
|
except Exception as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"recipe build failed: {type(exc).__name__}: {exc}",
|
|
) from exc
|