Files
TieMeasureFlow/src/vision_worker/main.py
T
Adriano 77419f9ebd vision: il DXF di confronto unisce le entita' aperte in contorni chiusi (Refs #103)
chiudi_contorni unisce LINE/ARC aperti in LWPOLYLINE chiuse prima di
costruire la ricetta e per l'anteprima: con banda larga cut_open_ends
non lasciava piu' punti in banda.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 16:11:18 +01:00

380 lines
15 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
import tempfile # #101
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, chiudi_contorni # #83 #103
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
# #101: tetto ai punti dell'anteprima DXF (un profilo fitto non deve gonfiare
# la risposta ne' rallentare il disegno SVG nel browser).
_PREVIEW_MAX_POINTS = 20000
def _decimate(points: list[list[float]], keep: int) -> list[list[float]]:
"""Decimazione uniforme a `keep` punti (>= 2), primo e ultimo sempre inclusi."""
n = len(points)
if n <= keep or keep < 2:
return points
idx = sorted({round(i * (n - 1) / (keep - 1)) for i in range(keep)})
return [points[i] for i in idx]
def _dxf_preview(dxf_bytes: bytes) -> dict:
"""#101: legge il DXF con lo stesso lettore di CompareDxf (read_xld_dxf) e ne
ritorna i contorni in mm, asse y verso il basso (come immagine e SVG).
Funzione sincrona e pura: la rotta la esegue in un thread. Solleva
CostruttoreError per DXF vuoto, illeggibile o senza geometria.
"""
if not dxf_bytes:
raise CostruttoreError("DXF illeggibile: file vuoto")
from visionsuite.io.dxf import read_xld_dxf
# #103: l'anteprima mostra cio' che il confronto usera' (contorni chiusi)
dxf_bytes, _ = chiudi_contorni(dxf_bytes)
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "disegno.dxf"
path.write_bytes(dxf_bytes)
try:
# flip_y=False + scale=1: coordinate native del DXF, in mm; il
# ribaltamento dell'asse y lo facciamo noi sotto (y = -y_dxf)
conts = list(read_xld_dxf(str(path), sampling_distance=0.05,
scale=1.0, flip_y=False))
except Exception as exc: # noqa: BLE001 - qualunque errore di lettura = DXF illeggibile
raise CostruttoreError(f"DXF illeggibile: {type(exc).__name__}: {exc}") from exc
contorni: list[list[list[float]]] = []
for cont in conts:
pts = np.asarray(cont.points, dtype=float) # (riga, colonna) = (y_dxf, x_dxf)
if pts.ndim != 2 or pts.shape[0] < 2:
continue
line = [[float(c), float(-r)] for r, c in pts]
if cont.closed and line[0] != line[-1]:
line.append(list(line[0])) # chiude il giro anche nella polilinea SVG
contorni.append(line)
if not contorni:
raise CostruttoreError("DXF senza geometria")
# #101: le dimensioni si misurano sui punti completi, prima della decimazione
xs = [p[0] for c in contorni for p in c]
ys = [p[1] for c in contorni for p in c]
xmin, xmax, ymin, ymax = min(xs), max(xs), min(ys), max(ys)
total = len(xs)
if total > _PREVIEW_MAX_POINTS:
# quota per contorno proporzionale, minimo 2 punti
contorni = [
_decimate(c, max(2, int(len(c) * _PREVIEW_MAX_POINTS / total))) for c in contorni
]
contorni = [[[round(x, 3), round(y, 3)] for x, y in c] for c in contorni]
xmin, xmax, ymin, ymax = (round(v, 3) for v in (xmin, xmax, ymin, ymax))
return {
"contorni": contorni,
"bbox": {"xmin": xmin, "ymin": ymin, "xmax": xmax, "ymax": ymax},
"larghezza_mm": round(xmax - xmin, 3),
"altezza_mm": round(ymax - ymin, 3),
"entita": len(contorni),
"punti": sum(len(c) for c in contorni),
}
@app.post("/dxf/preview")
async def dxf_preview(dxf: UploadFile = File(...)) -> dict:
"""#101: anteprima del disegno DXF (contorni + dimensioni in mm) per il
wizard «Crea ricetta da DXF». Solo lettura, leggera: nessun run slot."""
dxf_bytes = await dxf.read()
try:
return await asyncio.to_thread(_dxf_preview, dxf_bytes)
except CostruttoreError as exc:
raise _unprocessable(str(exc)) from exc
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"dxf preview failed: {type(exc).__name__}: {exc}",
) from exc