77419f9ebd
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>
380 lines
15 KiB
Python
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
|