Files
TieMeasureFlow/src/backend/services/vision_service.py
T

580 lines
22 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 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.task import RecipeSubtask, RecipeTask
from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
from src.backend.services import measurement_service
# 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,
) -> 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)}
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"{settings.vision_worker_url}/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}",
) 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}",
) 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 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=(",", ":"))
digest = hashlib.sha256(canonical.encode("utf-8")).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"
)
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",
)
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.
await measurement_service.validate_run_for_version(
db, production_run_id, version_id,
)
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"
),
)
# Nessuna fonte di calibrazione esiste oggi (ne' task ne' stazione): non si
# passa `calibration`, la ricetta porta la sua (#46).
report = await _call_worker(
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
)
outputs = report["outputs"]
# Every quote or none. A half-measured piece read as a whole one is worse
# than a piece not measured at all. A quote counts as produced only if its
# output is present *and* a finite, non-bool number: the in-process runner
# already drops NaN/inf itself (see src/vision/runner.py), but the worker
# is reached over HTTP - a different implementation of it, or a malformed
# response, could still hand back None or a non-finite float for a mapped
# key instead of omitting it outright.
missing = [q.vision_output for q in quotes if not _is_measurable(outputs.get(q.vision_output))]
if missing:
failed = ", ".join(f["tool_name"] for f in report["failures"])
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=(
f"the graph did not produce {', '.join(missing)}"
+ (f" (tools that failed: {failed})" if failed else "")
),
)
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"],
executed_on="server",
graph_snapshot=task.vision_json,
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,
value=float(outputs[quote.vision_output]),
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
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,
)