Worker: endpoint /recipes/dxf-compare con test (#83)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ import numpy as np
|
|||||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile, status
|
from fastapi import FastAPI, File, Form, HTTPException, UploadFile, status
|
||||||
from PIL import Image, UnidentifiedImageError
|
from PIL import Image, UnidentifiedImageError
|
||||||
|
|
||||||
|
from src.vision.costruttori import CostruttoreError, build_dxf_compare # #83
|
||||||
from src.vision.runner import (
|
from src.vision.runner import (
|
||||||
GraphBuildError,
|
GraphBuildError,
|
||||||
RunOutcome,
|
RunOutcome,
|
||||||
@@ -223,3 +224,67 @@ async def validate(
|
|||||||
validate_graph, parsed_graph, resolved_dir
|
validate_graph, parsed_graph, resolved_dir
|
||||||
)
|
)
|
||||||
return {"ok": ok, "errors": errors, "outputs": outputs}
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user