06f308be6f
Refs #118 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1008 lines
40 KiB
Python
1008 lines
40 KiB
Python
"""Route the image to the worker, map the outputs onto the quotes, save.
|
|
|
|
This module never imports VisionSuite - see test_vision_boundary.py. It speaks
|
|
to the worker over HTTP and stays ignorant of how a number was produced.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import math
|
|
from pathlib import Path
|
|
from uuid import uuid4
|
|
|
|
import httpx
|
|
from fastapi import HTTPException, status
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from src.backend.config import settings
|
|
from src.backend.models.orm.measurement import Measurement
|
|
from src.backend.models.orm.recipe import RecipeVersion
|
|
from src.backend.models.orm.station import Station
|
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
|
from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
|
|
from src.backend.services import measurement_service
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Worker responses whose status and reason are forwarded as-is rather than
|
|
# flattened into a generic 502 - see `_call_worker`.
|
|
_FORWARDED_WORKER_STATUSES = frozenset({
|
|
status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
status.HTTP_504_GATEWAY_TIMEOUT,
|
|
})
|
|
|
|
|
|
def _is_measurable(value: object) -> bool:
|
|
"""A worker output counts as a quote only if it is a finite, non-bool number.
|
|
|
|
None (absent, or explicitly null in a malformed response), a bool (a
|
|
subclass of int), and NaN/inf all fail this - each one would otherwise
|
|
reach `float()` and either crash with a bare TypeError or silently save a
|
|
value nobody could have measured.
|
|
"""
|
|
return (
|
|
isinstance(value, (int, float))
|
|
and not isinstance(value, bool)
|
|
and math.isfinite(value)
|
|
)
|
|
|
|
|
|
async def _call_worker(
|
|
image_bytes: bytes,
|
|
graph: dict,
|
|
*,
|
|
recipe_dir: str | None = None,
|
|
calibration: dict | None = None,
|
|
worker_url: str | None = None,
|
|
) -> dict:
|
|
"""The one place that talks to the worker; the tests replace it.
|
|
|
|
The worker's fault must not become the API's fault (spec's edge-case
|
|
table: "il worker isola il guasto, l'API resta in piedi, il task
|
|
riporta l'errore"). httpx.TimeoutException (ConnectTimeout, ReadTimeout,
|
|
WriteTimeout, PoolTimeout) becomes 504; every other transport failure
|
|
(ConnectError, ReadError, RemoteProtocolError when the worker dies
|
|
mid-response, ...) becomes 502 - both would otherwise propagate past
|
|
this function and surface to the operator as a naked 500. The response
|
|
shape is validated here too: a proxy answering 200 with something that
|
|
is not the worker's JSON contract must not turn into a bare KeyError
|
|
further down.
|
|
"""
|
|
# recipe_dir (relativo, #46) e calibration sono opzionali: se assenti non
|
|
# si mandano, il worker usa i suoi default.
|
|
data = {"graph": json.dumps(graph)}
|
|
# worker della stazione (#45) o, se manca, il centrale; mai un ripiego dall'uno all'altro
|
|
base = (worker_url or settings.vision_worker_url).rstrip("/")
|
|
if recipe_dir:
|
|
data["recipe_dir"] = recipe_dir
|
|
if calibration:
|
|
data["calibration"] = json.dumps(calibration)
|
|
try:
|
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
|
response = await client.post(
|
|
f"{base}/run",
|
|
files={"image": ("frame.png", image_bytes, "image/png")},
|
|
data=data,
|
|
)
|
|
except httpx.TimeoutException as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
|
detail=f"the vision worker did not answer in time: {exc} ({base})", # #45
|
|
) from exc
|
|
except (httpx.TransportError, httpx.HTTPError) as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail=f"the vision worker is unreachable: {exc} ({base})", # #45
|
|
) from exc
|
|
|
|
# The worker already gave this fault a name - forward its status and
|
|
# reason instead of flattening every non-200 into a generic 502: 422 is a
|
|
# bad request (undecodable image, malformed graph), 503 is "busy, try
|
|
# again" (VISION_MAX_CONCURRENCY exceeded), 504 is its own run timeout
|
|
# (VISION_RUN_TIMEOUT_S, always tighter than this client's own 120s so
|
|
# the worker answers first).
|
|
if response.status_code in _FORWARDED_WORKER_STATUSES:
|
|
try:
|
|
detail = response.json().get("detail", response.text)
|
|
except ValueError:
|
|
detail = response.text
|
|
raise HTTPException(status_code=response.status_code, detail=detail)
|
|
|
|
if response.status_code != 200:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail=f"the vision worker refused the job: {response.text}",
|
|
)
|
|
|
|
try:
|
|
report = response.json()
|
|
except ValueError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail=f"the vision worker's answer was malformed: not valid JSON ({exc})",
|
|
) from exc
|
|
|
|
if not isinstance(report, dict):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail="the vision worker's answer was malformed: expected a JSON object",
|
|
)
|
|
missing = {"outputs", "failures", "engine_version"} - report.keys()
|
|
if missing:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail=(
|
|
"the vision worker's answer was malformed: missing "
|
|
f"{', '.join(sorted(missing))}"
|
|
),
|
|
)
|
|
return report
|
|
|
|
|
|
async def call_worker_multipart(
|
|
path: str,
|
|
*,
|
|
files: dict,
|
|
data: dict,
|
|
worker_url: str | None = None,
|
|
timeout: float = 120.0,
|
|
) -> dict:
|
|
"""POST multipart generico al worker (#83): /recipes/dxf-compare.
|
|
|
|
Stessa traduzione degli errori di `_call_worker`: 504 sul timeout, 502 sul
|
|
trasporto o su una risposta non JSON-oggetto; 422/503/504 del worker si
|
|
inoltrano cosi' come sono. Worker della stazione o, se manca, il centrale.
|
|
"""
|
|
base = (worker_url or settings.vision_worker_url).rstrip("/")
|
|
try:
|
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
response = await client.post(f"{base}{path}", files=files, data=data)
|
|
except httpx.TimeoutException as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
|
detail=f"the vision worker did not answer in time: {exc} ({base})",
|
|
) from exc
|
|
except (httpx.TransportError, httpx.HTTPError) as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail=f"the vision worker is unreachable: {exc} ({base})",
|
|
) from exc
|
|
|
|
if response.status_code in _FORWARDED_WORKER_STATUSES:
|
|
try:
|
|
detail = response.json().get("detail", response.text)
|
|
except ValueError:
|
|
detail = response.text
|
|
raise HTTPException(status_code=response.status_code, detail=detail)
|
|
if response.status_code != 200:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail=f"the vision worker refused the job: {response.text}",
|
|
)
|
|
try:
|
|
body = response.json()
|
|
except ValueError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail=f"the vision worker's answer was malformed: not valid JSON ({exc})",
|
|
) from exc
|
|
if not isinstance(body, dict):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail="the vision worker's answer was malformed: expected a JSON object",
|
|
)
|
|
return body
|
|
|
|
|
|
async def dxf_compare_recipe(
|
|
image_bytes: bytes, dxf_bytes: bytes, *, px_per_mm: float, band_mm: float,
|
|
min_points_pct: float, polarity: str, worker_url: str | None = None,
|
|
) -> dict:
|
|
"""W2: ricetta dxf-compare da immagine + DXF (#83). Sostituita dai test."""
|
|
body = await call_worker_multipart(
|
|
"/recipes/dxf-compare",
|
|
files={
|
|
"image": ("frame.png", image_bytes, "image/png"),
|
|
"dxf": ("disegno.dxf", dxf_bytes, "application/octet-stream"),
|
|
},
|
|
data={
|
|
"px_per_mm": str(px_per_mm), "band_mm": str(band_mm),
|
|
"min_points_pct": str(min_points_pct), "polarity": polarity,
|
|
},
|
|
worker_url=worker_url,
|
|
)
|
|
# forma minima del contratto W2: senza graph e files non c'e' nulla da salvare
|
|
if not isinstance(body.get("graph"), dict) or not isinstance(body.get("files"), dict):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail="the vision worker's answer was malformed: missing graph or files",
|
|
)
|
|
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)
|
|
CAL_OK = "ok"
|
|
CAL_NO_CAMERA = "no_camera" # la stazione non ha camera_url/camera_source
|
|
CAL_NOT_CALIBRATED = "not_calibrated" # l'acquisitore risponde 404 (o un valore inutilizzabile)
|
|
CAL_UNREACHABLE = "unreachable" # l'acquisitore non risponde
|
|
|
|
|
|
async def fetch_calibration(
|
|
camera_url: str | None, camera_source: str | None, label: str = "",
|
|
) -> tuple[dict | None, str]:
|
|
"""Legge la calibrazione da un acquisitore (C1, #83; generalizzata in #97).
|
|
|
|
GET {camera_url}/camera/calibration?source={camera_source}. La calibrazione
|
|
vive nell'acquisitore: TMF la legge, non la tiene. Ritorna (dict, "ok")
|
|
oppure (None, motivo) per distinguere «non calibrata» da «irraggiungibile».
|
|
Un px_per_mm non finito o <= 0 conta come non calibrata. `label` serve
|
|
solo ai log (stazione o task).
|
|
"""
|
|
if not camera_url or not camera_source:
|
|
return None, CAL_NO_CAMERA
|
|
url = f"{camera_url.rstrip('/')}/camera/calibration"
|
|
try:
|
|
# timeout breve: e' una lettura di servizio, non deve bloccare l'execute
|
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
response = await client.get(url, params={"source": camera_source})
|
|
except httpx.HTTPError as exc:
|
|
logger.warning("acquisitore %s non raggiungibile: %s", label, exc)
|
|
return None, CAL_UNREACHABLE
|
|
if response.status_code == status.HTTP_404_NOT_FOUND:
|
|
return None, CAL_NOT_CALIBRATED
|
|
if response.status_code != 200:
|
|
logger.warning("acquisitore %s: HTTP %s", label, response.status_code)
|
|
return None, CAL_UNREACHABLE
|
|
try:
|
|
body = response.json()
|
|
except ValueError:
|
|
logger.warning("acquisitore %s: risposta non JSON", label)
|
|
return None, CAL_UNREACHABLE
|
|
px_per_mm = body.get("px_per_mm") if isinstance(body, dict) else None
|
|
if not (_is_measurable(px_per_mm) and px_per_mm > 0):
|
|
return None, CAL_NOT_CALIBRATED
|
|
return body, CAL_OK
|
|
|
|
|
|
async def fetch_station_calibration(station: Station | None) -> tuple[dict | None, str]:
|
|
"""Calibrazione dall'acquisitore della stazione (C1, #83): vedi fetch_calibration."""
|
|
if station is None:
|
|
return None, CAL_NO_CAMERA
|
|
return await fetch_calibration(
|
|
station.camera_url, station.camera_source, f"della stazione {station.code}",
|
|
)
|
|
|
|
|
|
def task_camera(task: RecipeTask, station: Station | None) -> tuple[str | None, str | None]:
|
|
"""Camera di un task (#97): acquisition_json, altrimenti quella della stazione."""
|
|
acquisition = task.acquisition_json
|
|
if isinstance(acquisition, dict) and acquisition.get("camera_url") and acquisition.get("camera_source"):
|
|
return acquisition["camera_url"], acquisition["camera_source"]
|
|
if station is not None:
|
|
return station.camera_url, station.camera_source
|
|
return None, None
|
|
|
|
|
|
async def fetch_task_calibration(
|
|
task: RecipeTask, station: Station | None,
|
|
) -> tuple[dict | None, str]:
|
|
"""Calibrazione della camera del task (#97), stessi esiti CAL_* di fetch_calibration."""
|
|
camera_url, camera_source = task_camera(task, station)
|
|
return await fetch_calibration(camera_url, camera_source, f"del task {task.id}")
|
|
|
|
|
|
async def get_station_calibration(station: Station | None) -> dict | None:
|
|
"""La calibrazione C1 della stazione, o None in ogni altro caso (#83)."""
|
|
calibration, _ = await fetch_station_calibration(station)
|
|
return calibration
|
|
|
|
|
|
async def validate_recipe(graph: dict, recipe_dir: str | None) -> dict:
|
|
"""Ask the worker's /validate whether a v2 recipe is loadable (#46).
|
|
|
|
Returns {"ok", "errors", "outputs"}. A worker 422 (e.g. recipe_dir
|
|
refused) is folded into ok=False; transport faults become 502/504 as in
|
|
`_call_worker`. Replaced by the tests.
|
|
"""
|
|
data = {"graph": json.dumps(graph)}
|
|
if recipe_dir:
|
|
data["recipe_dir"] = recipe_dir
|
|
try:
|
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
|
response = await client.post(
|
|
f"{settings.vision_worker_url}/validate", data=data,
|
|
)
|
|
except httpx.TimeoutException as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
|
detail=f"the vision worker did not answer in time: {exc}",
|
|
) from exc
|
|
except httpx.HTTPError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail=f"the vision worker is unreachable: {exc}",
|
|
) from exc
|
|
if response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT:
|
|
try:
|
|
detail = response.json().get("detail", response.text)
|
|
except ValueError:
|
|
detail = response.text
|
|
errors = detail if isinstance(detail, list) else [str(detail)]
|
|
return {"ok": False, "errors": [str(e) for e in errors], "outputs": []}
|
|
if response.status_code != 200:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail=f"the vision worker refused the job: {response.text}",
|
|
)
|
|
try:
|
|
body = response.json()
|
|
except ValueError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail="the vision worker's answer was malformed: not valid JSON",
|
|
) from exc
|
|
if not isinstance(body, dict) or "ok" not in body:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
detail="the vision worker's answer was malformed: missing ok",
|
|
)
|
|
body.setdefault("errors", [])
|
|
body.setdefault("outputs", [])
|
|
return body
|
|
|
|
|
|
# Task types that run a vision graph (#46).
|
|
VISION_TASK_TYPES = ("camera_measure", "dxf_compare")
|
|
|
|
|
|
def _safe_join(root: Path, relative: str) -> Path:
|
|
"""root/relative, refusing absolute paths, '..' and anything leaving root."""
|
|
candidate = Path(relative)
|
|
if candidate.is_absolute() or ".." in candidate.parts or not candidate.parts:
|
|
raise ValueError(f"unsafe path: {relative}")
|
|
full = (root / candidate).resolve()
|
|
if not full.is_relative_to(root.resolve()):
|
|
raise ValueError(f"unsafe path: {relative}")
|
|
return full
|
|
|
|
|
|
def collect_meta_paths(node: object) -> list[str]:
|
|
"""Every `path` string found in any dict under the recipe's `meta`."""
|
|
found: list[str] = []
|
|
if isinstance(node, dict):
|
|
for key, value in node.items():
|
|
if key == "path" and isinstance(value, str):
|
|
found.append(value)
|
|
else:
|
|
found.extend(collect_meta_paths(value))
|
|
elif isinstance(node, list):
|
|
for item in node:
|
|
found.extend(collect_meta_paths(item))
|
|
return found
|
|
|
|
|
|
def match_recipe_files(
|
|
recipe: dict, uploads: list[tuple[str, bytes]],
|
|
) -> tuple[dict[str, bytes], list[str]]:
|
|
"""Pair each uploaded file with a meta path by base name (#46).
|
|
|
|
Returns ({relative path: bytes}, errors). Errors list every upload that
|
|
has no name or matches no meta path, and every unsafe meta path. A meta
|
|
path left without a file is not an error here: /validate decides.
|
|
"""
|
|
paths = collect_meta_paths(recipe.get("meta", {}))
|
|
by_name: dict[str, str] = {}
|
|
for p in paths:
|
|
by_name.setdefault(Path(p.replace("\\", "/")).name, p)
|
|
placed: dict[str, bytes] = {}
|
|
errors: list[str] = []
|
|
for name, content in uploads:
|
|
if not name:
|
|
errors.append("a file has no name")
|
|
continue
|
|
target = by_name.get(Path(name.replace("\\", "/")).name)
|
|
if target is None:
|
|
errors.append(f"file {name!r} matches no path in the recipe's meta")
|
|
continue
|
|
try:
|
|
_safe_join(Path("."), target)
|
|
except ValueError:
|
|
errors.append(f"meta path {target!r} is not allowed")
|
|
continue
|
|
placed[target] = content
|
|
return placed, errors
|
|
|
|
|
|
def store_recipe_folder(
|
|
task_id: int, recipe: dict, files: dict[str, bytes],
|
|
) -> str:
|
|
"""Write the immutable recipe folder; return its path relative to uploads.
|
|
|
|
Folder name = first 16 hex of the recipe's sha256. If it already exists it
|
|
is left untouched (immutable: other task versions may reference it).
|
|
"""
|
|
canonical = json.dumps(recipe, sort_keys=True, separators=(",", ":"))
|
|
# Il digest copre anche i file (path ordinato + sha256 del contenuto): la
|
|
# stessa ricetta con file diversi (es. primo upload senza il DXF) deve dare
|
|
# una cartella diversa, altrimenti quella incompleta resterebbe per sempre
|
|
# perche' immutabile (#46).
|
|
h = hashlib.sha256(canonical.encode("utf-8"))
|
|
for rel in sorted(files):
|
|
h.update(b"\0" + rel.encode("utf-8") + b"\0")
|
|
h.update(hashlib.sha256(files[rel]).digest())
|
|
digest = h.hexdigest()[:16]
|
|
relative = f"vision/recipes/{task_id}/{digest}"
|
|
folder = settings.upload_path / relative
|
|
if folder.exists():
|
|
return relative
|
|
folder.mkdir(parents=True, exist_ok=True)
|
|
(folder / "recipe.json").write_text(
|
|
json.dumps(recipe, indent=2, ensure_ascii=False), encoding="utf-8",
|
|
)
|
|
for rel, content in files.items():
|
|
target = _safe_join(folder, rel)
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_bytes(content)
|
|
return relative
|
|
|
|
|
|
def list_recipe_files(recipe_dir: str | None) -> list[str]:
|
|
"""Files of a recipe folder, relative to it (recipe.json excluded)."""
|
|
if not recipe_dir:
|
|
return []
|
|
try:
|
|
folder = _safe_join(settings.upload_path, recipe_dir)
|
|
except ValueError:
|
|
return []
|
|
if not folder.is_dir():
|
|
return []
|
|
return sorted(
|
|
p.relative_to(folder).as_posix()
|
|
for p in folder.rglob("*") if p.is_file() and p.name != "recipe.json"
|
|
)
|
|
|
|
|
|
# #105: nomi e id della ricetta costruita da «Crea ricetta da DXF» (src/vision/costruttori.py).
|
|
_WIZARD_DXF_FILE = "disegno.dxf"
|
|
_WIZARD_CALIBRATION_FILE = "calibrazione.json"
|
|
_WIZARD_REFERENCE_STEM = "riferimento"
|
|
|
|
|
|
def _is_real_number(value: object) -> bool:
|
|
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
|
|
|
|
|
|
def dxf_compare_source(graph: dict | None, recipe_dir: str | None) -> dict | None:
|
|
"""#105: i parametri con cui il wizard ha costruito la ricetta, per riaprirlo.
|
|
|
|
Legge i valori dal grafo (tool `pezzo` LocateBlob, tool `dxf` CompareDxf) e i
|
|
file dalla cartella ricetta. None se il grafo non e' uno del wizard (il task
|
|
non ha ricetta, o l'ha caricata a mano): la rotta risponde 404. Pura: niente DB.
|
|
"""
|
|
if not isinstance(graph, dict) or not isinstance(graph.get("tools"), list):
|
|
return None
|
|
tools = {t.get("id"): t for t in graph["tools"] if isinstance(t, dict)}
|
|
pezzo, confronto = tools.get("pezzo"), tools.get("dxf")
|
|
if not pezzo or pezzo.get("kind") != "LocateBlob":
|
|
return None
|
|
if not confronto or confronto.get("kind") != "CompareDxf":
|
|
return None
|
|
p_values = pezzo.get("values") if isinstance(pezzo.get("values"), dict) else {}
|
|
c_values = confronto.get("values") if isinstance(confronto.get("values"), dict) else {}
|
|
band, min_pct = c_values.get("band_plus"), c_values.get("points_in_pct_nominal")
|
|
if not (_is_real_number(band) and _is_real_number(min_pct)):
|
|
return None
|
|
polarity = p_values.get("polarity")
|
|
if polarity not in ("dark", "light"):
|
|
polarity = "dark" # default di LocateBlob e della rotta di creazione
|
|
|
|
folder = None
|
|
if recipe_dir:
|
|
try:
|
|
folder = _safe_join(settings.upload_path, recipe_dir)
|
|
except (ValueError, OSError):
|
|
folder = None
|
|
if folder is not None and not folder.is_dir():
|
|
folder = None
|
|
|
|
px_per_mm = None
|
|
dxf_name = None
|
|
image_name = None
|
|
if folder is not None:
|
|
try:
|
|
cal = json.loads((folder / _WIZARD_CALIBRATION_FILE).read_text(encoding="utf-8"))
|
|
value = cal.get("px_per_mm") if isinstance(cal, dict) else None
|
|
if _is_real_number(value) and value > 0:
|
|
px_per_mm = float(value)
|
|
except (OSError, ValueError):
|
|
px_per_mm = None # manca o illeggibile: il wizard chiedera' la scala
|
|
if (folder / _WIZARD_DXF_FILE).is_file():
|
|
dxf_name = _WIZARD_DXF_FILE
|
|
for candidate in sorted(folder.glob(f"{_WIZARD_REFERENCE_STEM}.*")):
|
|
if candidate.is_file():
|
|
image_name = candidate.name
|
|
break
|
|
return {
|
|
"band_mm": float(band), "min_points_pct": float(min_pct), "polarity": polarity,
|
|
"px_per_mm": px_per_mm, "dxf": dxf_name, "image": image_name,
|
|
}
|
|
|
|
|
|
def recipe_file_path(recipe_dir: str | None, relative: str) -> Path | None:
|
|
"""#105: percorso di un file della cartella ricetta, o None se non sicuro o assente."""
|
|
if not recipe_dir:
|
|
return None
|
|
try:
|
|
folder = _safe_join(settings.upload_path, recipe_dir)
|
|
target = _safe_join(folder, relative)
|
|
except (ValueError, OSError):
|
|
return None
|
|
return target if target.is_file() else None
|
|
|
|
|
|
def save_result_files(
|
|
task_id: int, image_bytes: bytes, overlay: list, image_ext: str = "png",
|
|
) -> tuple[str, str]:
|
|
"""Save the executed image and its overlay JSON side by side (#46).
|
|
|
|
Returns (image_path, overlay_path), both relative to uploads.
|
|
"""
|
|
stem = uuid4().hex
|
|
base = Path("vision") / "results" / str(task_id)
|
|
(settings.upload_path / base).mkdir(parents=True, exist_ok=True)
|
|
image_rel = (base / f"{stem}.{image_ext}").as_posix()
|
|
overlay_rel = (base / f"{stem}.overlay.json").as_posix()
|
|
(settings.upload_path / image_rel).write_bytes(image_bytes)
|
|
(settings.upload_path / overlay_rel).write_text(json.dumps(overlay), encoding="utf-8")
|
|
return image_rel, overlay_rel
|
|
|
|
|
|
async def execute_task(
|
|
db: AsyncSession,
|
|
task: RecipeTask,
|
|
image_bytes: bytes,
|
|
measured_by: int,
|
|
*,
|
|
version_id: int,
|
|
lot_number: str | None = None,
|
|
serial_number: str | None = None,
|
|
production_run_id: int | None = None,
|
|
image_ext: str = "png",
|
|
) -> tuple[VisionResult, list[Measurement], dict]:
|
|
if task.task_type not in VISION_TASK_TYPES:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail=f"task type {task.task_type!r} is not a camera measurement",
|
|
)
|
|
|
|
version = (await db.execute(
|
|
select(RecipeVersion).where(RecipeVersion.id == version_id)
|
|
)).scalar_one_or_none()
|
|
if version is None or not version.is_current:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail="this task's recipe version is no longer current",
|
|
)
|
|
|
|
recipe = await measurement_service.recipe_of_version(db, version_id)
|
|
if recipe is None or not recipe.active:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail="this recipe is not active",
|
|
)
|
|
|
|
run = None
|
|
if production_run_id is not None:
|
|
# 404 if the run does not exist, 422 if it belongs to another recipe,
|
|
# 409 if it is closed or paused - same rule a manual reading follows.
|
|
run = await measurement_service.validate_run_for_version(
|
|
db, production_run_id, version_id,
|
|
)
|
|
# Worker della stazione della sessione di produzione (#45); senza run o senza URL: centrale
|
|
station_url = None
|
|
# Calibrazione letta dall'acquisitore (#83); None = non c'e'. #97: la camera e'
|
|
# quella del task se la ricetta ne sceglie una, altrimenti quella della stazione.
|
|
station = None
|
|
if run is not None:
|
|
station = (await db.execute(
|
|
select(Station).where(Station.id == run.station_id)
|
|
)).scalar_one_or_none()
|
|
station_url = (station.vision_worker_url or None) if station is not None else None
|
|
calibration, _ = await fetch_task_calibration(task, station)
|
|
|
|
if not task.vision_json:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail="this task carries no vision graph",
|
|
)
|
|
|
|
# Ordered by marker_number - the number the operator sees on the annotated
|
|
# image and in reports (RecipeSubtask.__repr__, statistics.py, report_service.py
|
|
# all key off it). Which quote is saved first must never be an accident of
|
|
# how SQLite/MySQL happens to return rows.
|
|
quotes = (await db.execute(
|
|
select(RecipeSubtask)
|
|
.where(RecipeSubtask.task_id == task.id)
|
|
.where(RecipeSubtask.vision_output.is_not(None))
|
|
.order_by(RecipeSubtask.marker_number)
|
|
)).scalars().all()
|
|
if not quotes:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail="no quote on this task names a vision output",
|
|
)
|
|
|
|
# Ruling R10: the gate is evaluated once per acquisition, not once per
|
|
# quote. `save_measurement`'s own gate models "moving on" between
|
|
# measurements taken one after another - a real later moment where the
|
|
# operator could leave a bad reading behind. A camera acquisition produces
|
|
# every quote of this task at the same instant: there is no "moving on"
|
|
# between them, so applying the sequential rule inside the batch would
|
|
# block on a fail this very request just saved. Check it once here,
|
|
# against the whole set this acquisition is about to produce, and pass
|
|
# enforce_tolerance_gate=False to every save_measurement call below.
|
|
#
|
|
# Checked before the worker runs (M3): a blocked operator finds out
|
|
# without burning a whole vision execution first - the worker is the
|
|
# expensive part, the gate check is not.
|
|
blocking = await measurement_service.pending_authorisation(
|
|
db, version_id, measured_by, production_run_id,
|
|
)
|
|
quote_ids = {q.id for q in quotes}
|
|
if blocking is not None and blocking.subtask_id not in quote_ids:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=(
|
|
"A measurement is out of tolerance and awaiting the supervisor: "
|
|
"authorise it or measure that quote again before going on"
|
|
),
|
|
)
|
|
|
|
# La calibrazione viene dall'acquisitore della stazione (#83); se non c'e',
|
|
# non si passa `calibration` e la ricetta porta la sua (#46).
|
|
worker_kwargs: dict = {}
|
|
if calibration is not None:
|
|
worker_kwargs["calibration"] = {"px_per_mm": float(calibration["px_per_mm"])}
|
|
report = await _call_worker(
|
|
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
|
|
worker_url=station_url, # #45
|
|
**worker_kwargs,
|
|
)
|
|
outputs = report["outputs"]
|
|
|
|
# #118: una quota che il grafo non produce non blocca piu' l'esecuzione
|
|
# (prima 422 "the graph did not produce"): viene salvata con value NULL,
|
|
# mostrata NaN, sempre `fail`; i motivi restano nei `failures` del risultato.
|
|
# Una quota conta come prodotta solo se l'uscita c'e' *ed* e' un numero
|
|
# finito non-bool: il worker risponde via HTTP, un'altra implementazione o
|
|
# una risposta malformata potrebbe dare None o un non finito per una chiave
|
|
# mappata invece di ometterla.
|
|
|
|
overlay = report.get("overlay") or []
|
|
image_path, overlay_path = save_result_files(task.id, image_bytes, overlay, image_ext)
|
|
result = VisionResult(
|
|
task_id=task.id,
|
|
image_path=image_path,
|
|
overlay_path=overlay_path,
|
|
engine_version=report["engine_version"],
|
|
# #45: dove e' girato davvero, e quale worker (copiato come lo snapshot)
|
|
executed_on="station" if station_url else "server",
|
|
station_id=run.station_id if run is not None else None,
|
|
worker_url=(station_url or settings.vision_worker_url).rstrip("/"),
|
|
graph_snapshot=task.vision_json,
|
|
# copiata come il grafo (#83): ricalibrare dopo non cambia cio' che e' stato misurato
|
|
calibration_snapshot=calibration,
|
|
duration_ms=report.get("duration_ms"),
|
|
)
|
|
db.add(result)
|
|
await db.flush()
|
|
|
|
saved: list[Measurement] = []
|
|
for quote in quotes:
|
|
saved.append(await measurement_service.save_measurement(
|
|
db,
|
|
subtask_id=quote.id,
|
|
version_id=version_id,
|
|
measured_by=measured_by,
|
|
# #118: None = quota non prodotta (NaN), salvata come fail
|
|
value=(
|
|
float(outputs[quote.vision_output])
|
|
if _is_measurable(outputs.get(quote.vision_output)) else None
|
|
),
|
|
lot_number=lot_number,
|
|
serial_number=serial_number,
|
|
input_method="camera",
|
|
production_run_id=production_run_id,
|
|
vision_result_id=result.id,
|
|
enforce_tolerance_gate=False,
|
|
))
|
|
|
|
return result, saved, report
|
|
|
|
|
|
async def check_task(
|
|
db: AsyncSession,
|
|
task: RecipeTask,
|
|
image_bytes: bytes,
|
|
*,
|
|
production_run_id: int | None = None,
|
|
) -> dict:
|
|
"""Esegue il grafo come execute_task ma NON salva nulla (#97).
|
|
|
|
Anteprima per l'operatore (ingresso nel task, ciclo «in linea»): niente
|
|
VisionResult, niente file, niente misure. L'esito per quota usa
|
|
calculate_pass_fail sulle quote con vision_output, nell'ordine di
|
|
marker_number come execute. Le quote che il grafo non produce (pezzo non
|
|
ancora in posizione) entrano in `measurements` con `value` None (NaN) e
|
|
`pass_fail` "fail" (#118, come execute); il perche' sta in `failures`:
|
|
un'anteprima non deve dare 422 a ogni frame.
|
|
"""
|
|
if task.task_type not in VISION_TASK_TYPES:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail=f"task type {task.task_type!r} is not a camera measurement",
|
|
)
|
|
station = None
|
|
station_url = None
|
|
if production_run_id is not None:
|
|
# stesse regole di execute: 404 / 422 / 409 sulla sessione di produzione
|
|
run = await measurement_service.validate_run_for_version(
|
|
db, production_run_id, task.version_id,
|
|
)
|
|
station = (await db.execute(
|
|
select(Station).where(Station.id == run.station_id)
|
|
)).scalar_one_or_none()
|
|
station_url = (station.vision_worker_url or None) if station is not None else None
|
|
if not task.vision_json:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail="this task carries no vision graph",
|
|
)
|
|
quotes = (await db.execute(
|
|
select(RecipeSubtask)
|
|
.where(RecipeSubtask.task_id == task.id)
|
|
.where(RecipeSubtask.vision_output.is_not(None))
|
|
.order_by(RecipeSubtask.marker_number)
|
|
)).scalars().all()
|
|
if not quotes:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail="no quote on this task names a vision output",
|
|
)
|
|
|
|
# stessa calibrazione di execute: camera del task, altrimenti della stazione
|
|
calibration, cal_reason = await fetch_task_calibration(task, station)
|
|
worker_kwargs: dict = {}
|
|
if calibration is not None:
|
|
worker_kwargs["calibration"] = {"px_per_mm": float(calibration["px_per_mm"])}
|
|
report = await _call_worker(
|
|
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
|
|
worker_url=station_url,
|
|
**worker_kwargs,
|
|
)
|
|
outputs = report["outputs"]
|
|
# #118: una voce per ogni quota, in ordine di marker_number; non prodotta -> NaN/fail
|
|
measurements = [
|
|
{
|
|
"subtask_id": q.id,
|
|
"value": float(outputs[q.vision_output]),
|
|
"pass_fail": measurement_service.calculate_pass_fail(
|
|
float(outputs[q.vision_output]), q,
|
|
)[0],
|
|
} if _is_measurable(outputs.get(q.vision_output)) else {
|
|
"subtask_id": q.id, "value": None, "pass_fail": "fail",
|
|
}
|
|
for q in quotes
|
|
]
|
|
return {
|
|
"outcome": report.get("outcome"),
|
|
"overlay": report.get("overlay") or [],
|
|
"failures": report.get("failures") or [],
|
|
"measurements": measurements,
|
|
"calibration": cal_reason,
|
|
}
|
|
|
|
|
|
def _reference_file_path(reference: VisionReferenceImage) -> Path:
|
|
return settings.upload_path / reference.path
|
|
|
|
|
|
def remove_reference_file(reference: VisionReferenceImage) -> None:
|
|
"""Delete a reference image's file, if it is still there.
|
|
|
|
Called by the router only once the row's fate is durable: after a
|
|
successful commit when the row was just deleted, or to undo a
|
|
`save_reference_image` whose row failed to commit (see both call sites).
|
|
`missing_ok` because "already gone" must never be an error here.
|
|
"""
|
|
_reference_file_path(reference).unlink(missing_ok=True)
|
|
|
|
|
|
async def file_still_referenced(db: AsyncSession, path: str) -> bool:
|
|
"""True if some VisionReferenceImage row still points at *path*.
|
|
|
|
Copy-on-write (#38.6) can leave several rows sharing the same file on
|
|
disk (the copy carries over the source's `path` rather than duplicating
|
|
the file). The router must call this, after the row being deleted has
|
|
been committed, before it calls `remove_reference_file` - otherwise
|
|
deleting one task's reference would take the file out from under every
|
|
other task that still references it.
|
|
"""
|
|
result = await db.execute(
|
|
select(VisionReferenceImage.id).where(VisionReferenceImage.path == path).limit(1)
|
|
)
|
|
return result.scalar_one_or_none() is not None
|
|
|
|
|
|
async def save_reference_image(
|
|
db: AsyncSession,
|
|
task_id: int,
|
|
image_bytes: bytes,
|
|
*,
|
|
note: str | None = None,
|
|
station_id: int | None = None,
|
|
device_code: str | None = None,
|
|
calibration_snapshot: dict | None = None,
|
|
extension: str = "png",
|
|
) -> VisionReferenceImage:
|
|
"""Keep an image so the graph can be composed and re-tried without a camera.
|
|
|
|
`extension` must match the upload's real content type (the router derives
|
|
it from `image.content_type`) - writing a jpeg to disk as `<uuid>.png`
|
|
would be a lie the filename tells forever.
|
|
|
|
The file is written only after the row has flushed successfully - a bad
|
|
FK or constraint fails at `flush()`, before anything exists on disk, so
|
|
there is nothing to clean up. The caller still has to remove the file if
|
|
the *commit* that follows fails (`remove_reference_file`, from the
|
|
router): a flush is not durable, and an orphaned file from a rolled-back
|
|
row is exactly what #38 found.
|
|
"""
|
|
# `upload_path` is the resolved Path, `upload_dir` the bare setting: the
|
|
# rest of the codebase uses the former (see production_export_service).
|
|
name = f"{uuid4().hex}.{extension}"
|
|
relative_path = str(Path("vision") / "reference" / str(task_id) / name)
|
|
|
|
reference = VisionReferenceImage(
|
|
task_id=task_id,
|
|
path=relative_path,
|
|
note=note,
|
|
station_id=station_id,
|
|
device_code=device_code,
|
|
calibration_snapshot=calibration_snapshot,
|
|
)
|
|
db.add(reference)
|
|
await db.flush()
|
|
|
|
folder = _reference_file_path(reference).parent
|
|
folder.mkdir(parents=True, exist_ok=True)
|
|
_reference_file_path(reference).write_bytes(image_bytes)
|
|
|
|
await db.refresh(reference)
|
|
return reference
|
|
|
|
|
|
async def list_reference_images(
|
|
db: AsyncSession, task_id: int,
|
|
) -> list[VisionReferenceImage]:
|
|
result = await db.execute(
|
|
select(VisionReferenceImage)
|
|
.where(VisionReferenceImage.task_id == task_id)
|
|
.order_by(VisionReferenceImage.acquired_at)
|
|
)
|
|
return list(result.scalars().all())
|
|
|
|
|
|
async def delete_reference_image(
|
|
db: AsyncSession, reference_id: int,
|
|
) -> VisionReferenceImage:
|
|
"""Flush the row's deletion. The caller (router) removes the file only
|
|
once the commit that makes the deletion durable has actually succeeded -
|
|
deleting it any earlier would leave the row pointing at a file that is
|
|
already gone if the commit then failed.
|
|
"""
|
|
reference = (await db.execute(
|
|
select(VisionReferenceImage).where(VisionReferenceImage.id == reference_id)
|
|
)).scalar_one_or_none()
|
|
if reference is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Reference image not found",
|
|
)
|
|
await db.delete(reference)
|
|
await db.flush()
|
|
return reference
|
|
|
|
|
|
async def preview(db: AsyncSession, task: RecipeTask, image_bytes: bytes) -> dict:
|
|
"""Run the graph and save nothing. Composing is not measuring."""
|
|
if not task.vision_json:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail="this task carries no vision graph",
|
|
)
|
|
return await _call_worker(
|
|
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
|
|
)
|
|
|
|
|
|
async def ensure_points_quote(
|
|
db: AsyncSession, task: RecipeTask, min_points_pct: float,
|
|
) -> bool:
|
|
"""Crea la quota «Punti in tolleranza» se il task non ha nessuna quota di visione (#83).
|
|
|
|
Una sola volta: se esiste gia' una quota con `vision_output`, non si tocca.
|
|
Se la versione ha gia' misure non si scrive (il copy-on-write sta nel router
|
|
dei task, qui non si aggira): ritorna False. Ritorna True se l'ha creata.
|
|
#105: unica eccezione, la quota nata da qui (`dxf.points_in_pct`, «Punti in
|
|
tolleranza») senza misure segue la soglia nuova: ltl = min_points_pct, e
|
|
ritorna True anche in quel caso.
|
|
"""
|
|
from src.backend.services import recipe_service # import locale: evita cicli
|
|
|
|
# #105: ricetta ripresa e salvata con un'altra soglia -> la quota la segue
|
|
own = (await db.execute(
|
|
select(RecipeSubtask)
|
|
.where(RecipeSubtask.task_id == task.id)
|
|
.where(RecipeSubtask.vision_output == "dxf.points_in_pct")
|
|
.where(RecipeSubtask.description == "Punti in tolleranza")
|
|
.limit(1)
|
|
)).scalar_one_or_none()
|
|
if own is not None:
|
|
# soglia invariata o versione con misure: la quota resta com'e'
|
|
same = own.ltl is not None and float(own.ltl) == float(min_points_pct)
|
|
if same or await recipe_service.version_has_measurements(db, task.version_id):
|
|
return False
|
|
own.ltl = min_points_pct
|
|
await db.flush()
|
|
return True
|
|
|
|
existing = (await db.execute(
|
|
select(RecipeSubtask.id)
|
|
.where(RecipeSubtask.task_id == task.id)
|
|
.where(RecipeSubtask.vision_output.is_not(None))
|
|
.limit(1)
|
|
)).scalar_one_or_none()
|
|
if existing is not None:
|
|
return False
|
|
if await recipe_service.version_has_measurements(db, task.version_id):
|
|
return False
|
|
markers = (await db.execute(
|
|
select(RecipeSubtask.marker_number).where(RecipeSubtask.task_id == task.id)
|
|
)).scalars().all()
|
|
db.add(RecipeSubtask(
|
|
task_id=task.id,
|
|
marker_number=max(markers, default=0) + 1, # unico per task (uq_task_marker)
|
|
description="Punti in tolleranza",
|
|
nominal=100,
|
|
# limiti assoluti come nel resto delle quote: passa se punti% >= min_points_pct
|
|
ltl=min_points_pct,
|
|
utl=100,
|
|
unit="%",
|
|
vision_output="dxf.points_in_pct",
|
|
))
|
|
await db.flush()
|
|
return True
|