diff --git a/src/vision_worker/main.py b/src/vision_worker/main.py index c86d665..ef805ab 100644 --- a/src/vision_worker/main.py +++ b/src/vision_worker/main.py @@ -17,6 +17,7 @@ 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, @@ -223,3 +224,67 @@ async def validate( 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 diff --git a/src/vision_worker/tests/test_dxf_compare.py b/src/vision_worker/tests/test_dxf_compare.py new file mode 100644 index 0000000..877311c --- /dev/null +++ b/src/vision_worker/tests/test_dxf_compare.py @@ -0,0 +1,82 @@ +"""POST /recipes/dxf-compare del worker (#83): forma della risposta ed errori.""" +import pytest + +# Come test_worker.py: serve l'albero vision (Python >=3.13), altrimenti skipped. +pytest.importorskip( + "visionsuite.task", + reason="the vision tree requires Python >=3.13; see test_worker.py", +) + +import base64 +import io +import time + +import numpy as np +from httpx import ASGITransport, AsyncClient +from PIL import Image + +from src.vision.tests.test_costruttori import PX_PER_MM, SAMPLE_DXF, a_scene +from src.vision_worker.main import app + + +def _png(array: np.ndarray) -> bytes: + buffer = io.BytesIO() + Image.fromarray(array).save(buffer, format="PNG") + return buffer.getvalue() + + +async def _post(image: bytes, dxf: bytes, **data): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://worker") as client: + return await client.post( + "/recipes/dxf-compare", + files={"image": ("riferimento.png", image, "image/png"), + "dxf": ("disegno.dxf", dxf, "application/octet-stream")}, + data={"px_per_mm": str(PX_PER_MM), "band_mm": "2.0", **data}, + ) + + +@pytest.mark.asyncio +async def test_dxf_compare_200_con_le_chiavi_del_contratto(): + inizio = time.monotonic() + response = await _post(_png(a_scene(0.0)), SAMPLE_DXF.read_bytes()) + durata = time.monotonic() - inizio + + assert response.status_code == 200, response.text + body = response.json() + assert {"graph", "files", "outputs", "overlay", "outcome"} <= set(body) + assert set(body["files"]) == {"disegno.dxf", "calibrazione.json"} + assert base64.b64decode(body["files"]["disegno.dxf"]) == SAMPLE_DXF.read_bytes() + assert set(body["outputs"]) == { + "dxf.points_in_pct", "dxf.distance_avg", "dxf.distance_min", "dxf.distance_max"} + assert body["outputs"]["dxf.points_in_pct"] > 95.0 + assert body["graph"]["schema_version"] == 2 + print(f"\nW2 sulla scena di prova: {durata:.2f}s") # riportato nel rapporto + + +@pytest.mark.asyncio +async def test_dxf_compare_immagine_senza_pezzo_e_422(): + bianca = np.full((600, 800), 255, np.uint8) + response = await _post(_png(bianca), SAMPLE_DXF.read_bytes()) + assert response.status_code == 422 + assert "pezzo" in response.json()["detail"] + + +@pytest.mark.asyncio +async def test_dxf_compare_dxf_illeggibile_e_422(): + response = await _post(_png(a_scene(0.0)), b"non e' un dxf") + assert response.status_code == 422 + assert "DXF" in response.json()["detail"] + + +@pytest.mark.asyncio +async def test_dxf_compare_immagine_non_decodificabile_e_422(): + response = await _post(b"non e' un'immagine", SAMPLE_DXF.read_bytes()) + assert response.status_code == 422 + assert "not decodable" in response.json()["detail"].lower() + + +@pytest.mark.asyncio +async def test_dxf_compare_parametro_fuori_range_e_422(): + response = await _post(_png(a_scene(0.0)), SAMPLE_DXF.read_bytes(), polarity="grigio") + assert response.status_code == 422