vision: anteprima DXF nel worker e nel backend (Refs #101)
Worker: POST /dxf/preview legge il DXF con read_xld_dxf (lo stesso lettore di CompareDxf) e ritorna contorni in mm con y verso il basso, bbox, dimensioni, entita e punti (tetto 20000, decimazione uniforme). Backend: POST /api/vision/dxf-preview (Maker) che inoltra al worker; 422 inoltrato. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -366,6 +366,20 @@ async def preview(
|
|||||||
return await vision_service.preview(db, task, await _read_image(image))
|
return await vision_service.preview(db, task, await _read_image(image))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/dxf-preview")
|
||||||
|
async def dxf_preview(
|
||||||
|
dxf: UploadFile = File(...),
|
||||||
|
current_user: User = Depends(require_maker),
|
||||||
|
) -> dict:
|
||||||
|
"""#101: anteprima del DXF scelto nel wizard «Crea ricetta da DXF» (contorni
|
||||||
|
in mm e dimensioni), letto dal worker con lo stesso lettore del confronto.
|
||||||
|
Non scrive nulla; stesso limite di dimensione della rotta dxf-compare."""
|
||||||
|
dxf_bytes = await dxf.read()
|
||||||
|
if not validate_file_size(len(dxf_bytes)):
|
||||||
|
raise _422([f"the DXF exceeds {settings.max_upload_size_mb}MB"])
|
||||||
|
return await vision_service.dxf_preview(dxf_bytes)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/tasks/{task_id}/recipe/dxf-compare")
|
@router.post("/tasks/{task_id}/recipe/dxf-compare")
|
||||||
async def create_dxf_compare_recipe(
|
async def create_dxf_compare_recipe(
|
||||||
task_id: int,
|
task_id: int,
|
||||||
|
|||||||
@@ -224,6 +224,25 @@ async def dxf_compare_recipe(
|
|||||||
return body
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
async def dxf_preview(dxf_bytes: bytes, worker_url: str | None = None) -> dict:
|
||||||
|
"""#101: anteprima del DXF (contorni in mm + dimensioni) dal worker. Sostituita dai test."""
|
||||||
|
body = await call_worker_multipart(
|
||||||
|
"/dxf/preview",
|
||||||
|
files={"dxf": ("disegno.dxf", dxf_bytes, "application/octet-stream")},
|
||||||
|
data={},
|
||||||
|
worker_url=worker_url,
|
||||||
|
)
|
||||||
|
# forma minima del contratto: senza contorni e dimensioni non c'e' nulla da mostrare
|
||||||
|
required = {"contorni", "bbox", "larghezza_mm", "altezza_mm", "entita", "punti"}
|
||||||
|
if not required <= body.keys():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail="the vision worker's answer was malformed: missing "
|
||||||
|
+ ", ".join(sorted(required - body.keys())),
|
||||||
|
)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
# Stati di fetch_station_calibration (#83)
|
# Stati di fetch_station_calibration (#83)
|
||||||
CAL_OK = "ok"
|
CAL_OK = "ok"
|
||||||
CAL_NO_CAMERA = "no_camera" # la stazione non ha camera_url/camera_source
|
CAL_NO_CAMERA = "no_camera" # la stazione non ha camera_url/camera_source
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""#101: POST /api/vision/dxf-preview, anteprima del DXF nel wizard.
|
||||||
|
|
||||||
|
Il worker non gira nei test: `vision_service.dxf_preview` si sostituisce.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from src.backend.config import settings
|
||||||
|
from src.backend.services import vision_service
|
||||||
|
from src.backend.tests.conftest import auth_headers
|
||||||
|
|
||||||
|
BODY = {
|
||||||
|
"contorni": [[[0.0, 0.0], [10.0, 0.0], [10.0, 5.0], [0.0, 5.0], [0.0, 0.0]]],
|
||||||
|
"bbox": {"xmin": 0.0, "ymin": 0.0, "xmax": 10.0, "ymax": 5.0},
|
||||||
|
"larghezza_mm": 10.0, "altezza_mm": 5.0, "entita": 1, "punti": 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _post(client, user, content=b"0\nEOF"):
|
||||||
|
return client.post(
|
||||||
|
"/api/vision/dxf-preview",
|
||||||
|
headers=auth_headers(user),
|
||||||
|
files={"dxf": ("d.dxf", content, "application/octet-stream")},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_preview_forwards_the_file_and_returns_the_worker_body(
|
||||||
|
client, maker_user, monkeypatch,
|
||||||
|
):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
async def fake(dxf_bytes, worker_url=None):
|
||||||
|
calls.append(dxf_bytes)
|
||||||
|
return BODY
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.backend.services.vision_service.dxf_preview", fake)
|
||||||
|
resp = await _post(client, maker_user, b"DXF-BYTES")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json() == BODY
|
||||||
|
assert calls == [b"DXF-BYTES"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_preview_forwards_the_worker_422(client, maker_user, monkeypatch):
|
||||||
|
async def fake(dxf_bytes, worker_url=None):
|
||||||
|
raise HTTPException(status_code=422, detail="DXF illeggibile: file vuoto")
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.backend.services.vision_service.dxf_preview", fake)
|
||||||
|
resp = await _post(client, maker_user, b"")
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"] == "DXF illeggibile: file vuoto"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_preview_requires_maker(client, measurement_tec_user, monkeypatch):
|
||||||
|
async def fake(dxf_bytes, worker_url=None):
|
||||||
|
return BODY
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.backend.services.vision_service.dxf_preview", fake)
|
||||||
|
resp = await _post(client, measurement_tec_user)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_preview_too_big_is_422(client, maker_user, monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "max_upload_size_mb", 0)
|
||||||
|
resp = await _post(client, maker_user, b"x" * 10)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_preview_service_posts_to_worker_and_checks_shape(monkeypatch):
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
async def fake_multipart(path, *, files, data, worker_url=None, timeout=120.0):
|
||||||
|
seen.update(path=path, files=files)
|
||||||
|
return BODY
|
||||||
|
|
||||||
|
monkeypatch.setattr(vision_service, "call_worker_multipart", fake_multipart)
|
||||||
|
assert await vision_service.dxf_preview(b"abc") == BODY
|
||||||
|
assert seen["path"] == "/dxf/preview" and seen["files"]["dxf"][1] == b"abc"
|
||||||
|
|
||||||
|
async def malformed(path, *, files, data, worker_url=None, timeout=120.0):
|
||||||
|
return {"contorni": []}
|
||||||
|
|
||||||
|
monkeypatch.setattr(vision_service, "call_worker_multipart", malformed)
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
await vision_service.dxf_preview(b"abc")
|
||||||
|
assert exc.value.status_code == 502
|
||||||
@@ -11,6 +11,7 @@ import io
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import tempfile # #101
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -288,3 +289,89 @@ async def recipes_dxf_compare(
|
|||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail=f"recipe build failed: {type(exc).__name__}: {exc}",
|
detail=f"recipe build failed: {type(exc).__name__}: {exc}",
|
||||||
) from 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
|
||||||
|
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
|
||||||
|
|||||||
@@ -80,3 +80,67 @@ async def test_dxf_compare_immagine_non_decodificabile_e_422():
|
|||||||
async def test_dxf_compare_parametro_fuori_range_e_422():
|
async def test_dxf_compare_parametro_fuori_range_e_422():
|
||||||
response = await _post(_png(a_scene(0.0)), SAMPLE_DXF.read_bytes(), polarity="grigio")
|
response = await _post(_png(a_scene(0.0)), SAMPLE_DXF.read_bytes(), polarity="grigio")
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
# --- #101: POST /dxf/preview ---------------------------------------------------
|
||||||
|
|
||||||
|
def _rect_dxf(width: float, height: float, y0: float = 3.0) -> bytes:
|
||||||
|
"""DXF minimo con ezdxf: un rettangolo chiuso, y in alto rispetto a 0."""
|
||||||
|
import ezdxf
|
||||||
|
doc = ezdxf.new()
|
||||||
|
doc.modelspace().add_lwpolyline(
|
||||||
|
[(1, y0), (1 + width, y0), (1 + width, y0 + height), (1, y0 + height)], close=True,
|
||||||
|
)
|
||||||
|
buffer = io.StringIO()
|
||||||
|
doc.write(buffer)
|
||||||
|
return buffer.getvalue().encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _post_preview(dxf: bytes):
|
||||||
|
# TestClient sincrono: non serve pytest-asyncio (assente nel venv del worker)
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
return TestClient(app).post(
|
||||||
|
"/dxf/preview", files={"dxf": ("d.dxf", dxf, "application/octet-stream")},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dxf_preview_rettangolo_10x5():
|
||||||
|
resp = _post_preview(_rect_dxf(10, 5))
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["larghezza_mm"] == 10 and body["altezza_mm"] == 5
|
||||||
|
assert body["entita"] == 1 and body["punti"] == sum(len(c) for c in body["contorni"])
|
||||||
|
# y ribaltata: il rettangolo stava a y 3..8 nel DXF, ora a -8..-3 (asse verso il basso)
|
||||||
|
assert body["bbox"] == {"xmin": 1, "ymin": -8, "xmax": 11, "ymax": -3}
|
||||||
|
ring = body["contorni"][0]
|
||||||
|
assert ring[0] == ring[-1] # contorno chiuso: primo == ultimo
|
||||||
|
assert all(-8 <= y <= -3 for _, y in ring)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("content", [b"", b"questo non e' un dxf"])
|
||||||
|
def test_dxf_preview_illeggibile_e_422(content):
|
||||||
|
resp = _post_preview(content)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"].startswith("DXF")
|
||||||
|
|
||||||
|
|
||||||
|
def test_dxf_preview_senza_geometria_e_422():
|
||||||
|
import ezdxf
|
||||||
|
doc = ezdxf.new()
|
||||||
|
buffer = io.StringIO()
|
||||||
|
doc.write(buffer)
|
||||||
|
resp = _post_preview(buffer.getvalue().encode("utf-8"))
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert "senza geometria" in resp.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_dxf_preview_decima_oltre_il_tetto(monkeypatch):
|
||||||
|
from src.vision_worker import main
|
||||||
|
monkeypatch.setattr(main, "_PREVIEW_MAX_POINTS", 50)
|
||||||
|
import ezdxf
|
||||||
|
doc = ezdxf.new()
|
||||||
|
doc.modelspace().add_circle((0, 0), 20)
|
||||||
|
buffer = io.StringIO()
|
||||||
|
doc.write(buffer)
|
||||||
|
full = main._dxf_preview(buffer.getvalue().encode("utf-8"))
|
||||||
|
assert full["punti"] <= 50 + 1 and full["larghezza_mm"] == pytest.approx(40, abs=0.01) # dimensioni dai punti completi
|
||||||
|
|||||||
Reference in New Issue
Block a user