backend: camera e modo di analisi nel task, /vision/check senza salvataggio (Refs #97)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 18:20:14 +02:00
parent 552f96bb47
commit 8f9731839a
8 changed files with 216 additions and 16 deletions
+2
View File
@@ -250,6 +250,8 @@ async def create_task(
file_type=data.file_type, file_type=data.file_type,
annotations_json=data.annotations_json, annotations_json=data.annotations_json,
vision_json=data.vision_json, vision_json=data.vision_json,
# #97: camera e modo di analisi (dict gia' validato dal modello)
acquisition_json=data.acquisition_json.model_dump() if data.acquisition_json else None,
# #53 #55: ciclo (default dal tipo), chiusura, campi extra # #53 #55: ciclo (default dal tipo), chiusura, campi extra
in_loop=data.in_loop if data.in_loop is not None else default_in_loop(data.task_type), in_loop=data.in_loop if data.in_loop is not None else default_in_loop(data.task_type),
at_close=data.at_close, at_close=data.at_close,
+17 -1
View File
@@ -23,7 +23,7 @@ from src.backend.api.routers.files import (
from src.backend.config import settings from src.backend.config import settings
from src.backend.database import get_db from src.backend.database import get_db
from src.backend.models.api.vision import ( from src.backend.models.api.vision import (
VisionExecuteResponse, VisionMeasurementResult, VisionRecipeResponse, VisionCheckResponse, VisionExecuteResponse, VisionMeasurementResult, VisionRecipeResponse,
VisionRecipeUploadResponse, VisionReferenceImageResponse, VisionRecipeUploadResponse, VisionReferenceImageResponse,
) )
from src.backend.models.orm.vision import VisionResult from src.backend.models.orm.vision import VisionResult
@@ -164,6 +164,22 @@ async def execute(
) )
@router.post("/check", response_model=VisionCheckResponse)
async def check(
task_id: int = Form(...),
image: UploadFile = File(...),
production_run_id: int | None = Form(None, gt=0),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_measurement_tec),
) -> VisionCheckResponse:
"""#97: analisi di un frame senza salvare nulla (ne' risultato ne' misure)."""
task = await _task_or_404(db, task_id)
report = await vision_service.check_task(
db, task, await _read_image(image), production_run_id=production_run_id,
)
return VisionCheckResponse(**report)
@router.get("/results/{result_id}/image") @router.get("/results/{result_id}/image")
async def get_result_image( async def get_result_image(
result_id: int, result_id: int,
@@ -0,0 +1,31 @@
"""recipe_tasks.acquisition_json (#97)
recipe_tasks.acquisition_json: camera e modo di analisi scelti nel task,
{"camera_url", "camera_source", "modo": "impulsiva" | "in_linea"}; NULL = la
camera della stazione dell'operatore, analisi impulsiva (come prima).
Revision ID: 021_task_acquisition
Revises: 020_station_camera
Create Date: 2026-10-24
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '021_task_acquisition'
down_revision: Union[str, None] = '020_station_camera'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# batch_alter_table come nelle migrazioni vicine: serve a SQLite
with op.batch_alter_table('recipe_tasks') as batch:
batch.add_column(sa.Column('acquisition_json', sa.JSON(), nullable=True))
def downgrade() -> None:
with op.batch_alter_table('recipe_tasks') as batch:
batch.drop_column('acquisition_json')
+29 -1
View File
@@ -2,8 +2,9 @@
import re import re
from typing import Any, Literal, Optional from typing import Any, Literal, Optional
from pydantic import BaseModel, ConfigDict, Field, model_validator from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from src.backend.models.api.station import _normalize_http_url
from src.backend.models.orm.task import DEFAULT_LOOP_TASK_TYPES, TASK_TYPES from src.backend.models.orm.task import DEFAULT_LOOP_TASK_TYPES, TASK_TYPES
# Kept in step with the ORM enum so the two cannot drift apart. # Kept in step with the ORM enum so the two cannot drift apart.
@@ -159,6 +160,30 @@ class SubtaskResponse(BaseModel):
vision_output: Optional[str] = None vision_output: Optional[str] = None
class AcquisitionConfig(BaseModel):
"""#97: camera (servizio + sorgente) e modo di analisi scelti nel task."""
camera_url: str = Field(..., max_length=500)
camera_source: str = Field(..., min_length=1, max_length=200)
modo: Literal["impulsiva", "in_linea"] = "impulsiva"
@field_validator("camera_url")
@classmethod
def _check_camera_url(cls, value: str) -> str:
# stessa normalizzazione delle stazioni; qui il vuoto non e' ammesso
normalized = _normalize_http_url(value, "camera_url")
if normalized is None:
raise ValueError("camera_url must not be empty")
return normalized
@field_validator("camera_source")
@classmethod
def _check_camera_source(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("camera_source must not be empty")
return value
class TaskCreate(BaseModel): class TaskCreate(BaseModel):
"""Schema for creating a task.""" """Schema for creating a task."""
title: str = Field(..., min_length=1, max_length=255) title: str = Field(..., min_length=1, max_length=255)
@@ -169,6 +194,7 @@ class TaskCreate(BaseModel):
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$") file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
annotations_json: Optional[dict[str, Any]] = None annotations_json: Optional[dict[str, Any]] = None
vision_json: Optional[dict] = None vision_json: Optional[dict] = None
acquisition_json: Optional[AcquisitionConfig] = None # #97
# #53: assente = default dal tipo (misure e check nel ciclo) # #53: assente = default dal tipo (misure e check nel ciclo)
in_loop: Optional[bool] = None in_loop: Optional[bool] = None
at_close: bool = False at_close: bool = False
@@ -186,6 +212,7 @@ class TaskUpdate(BaseModel):
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$") file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
annotations_json: Optional[dict[str, Any]] = None annotations_json: Optional[dict[str, Any]] = None
vision_json: Optional[dict] = None vision_json: Optional[dict] = None
acquisition_json: Optional[AcquisitionConfig] = None # #97
in_loop: Optional[bool] = None in_loop: Optional[bool] = None
at_close: Optional[bool] = None at_close: Optional[bool] = None
fields: Optional[list[dict[str, Any]]] = None fields: Optional[list[dict[str, Any]]] = None
@@ -207,6 +234,7 @@ class TaskResponse(BaseModel):
file_type: Optional[str] = None file_type: Optional[str] = None
annotations_json: Optional[dict[str, Any]] = None annotations_json: Optional[dict[str, Any]] = None
vision_json: Optional[dict] = None vision_json: Optional[dict] = None
acquisition_json: Optional[AcquisitionConfig] = None # #97
in_loop: bool = False in_loop: bool = False
at_close: bool = False at_close: bool = False
fields: Optional[list[dict[str, Any]]] = None fields: Optional[list[dict[str, Any]]] = None
+10
View File
@@ -24,6 +24,16 @@ class VisionExecuteResponse(BaseModel):
failures: list[dict] = [] failures: list[dict] = []
class VisionCheckResponse(BaseModel):
"""What `POST /api/vision/check` returns (#97): the analysis, nothing saved."""
outcome: str | None = None
overlay: list[dict] = []
failures: list[dict] = []
measurements: list[VisionMeasurementResult] = []
# esito della lettura della calibrazione: ok | no_camera | not_calibrated | unreachable
calibration: str
class VisionRecipeOutput(BaseModel): class VisionRecipeOutput(BaseModel):
"""One numeric output a recipe offers to the quotes.""" """One numeric output a recipe offers to the quotes."""
key: str key: str
+3
View File
@@ -65,6 +65,9 @@ class RecipeTask(Base):
# Cartella immutabile (relativa a upload_path) con recipe.json e i file di # Cartella immutabile (relativa a upload_path) con recipe.json e i file di
# meta della ricetta VisionSuite v2 (#46). # meta della ricetta VisionSuite v2 (#46).
vision_recipe_dir: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) vision_recipe_dir: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
# #97: camera e modo di analisi del task, {camera_url, camera_source, modo};
# NULL = camera della stazione corrente, analisi impulsiva.
acquisition_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
# #53: il task fa parte del ciclo di produzione (default dal tipo: misure e # #53: il task fa parte del ciclo di produzione (default dal tipo: misure e
# check si', gli altri no; un task 'note' si puo' mettere nel ciclo a mano). # check si', gli altri no; un task 'note' si puo' mettere nel ciclo a mano).
in_loop: Mapped[bool] = mapped_column( in_loop: Mapped[bool] = mapped_column(
+2
View File
@@ -117,6 +117,8 @@ async def _copy_tasks_to_version(
vision_json=task.vision_json, vision_json=task.vision_json,
# Cartella immutabile: basta il riferimento (#46). # Cartella immutabile: basta il riferimento (#46).
vision_recipe_dir=task.vision_recipe_dir, vision_recipe_dir=task.vision_recipe_dir,
# #97: camera e modo di analisi seguono il task nella nuova versione.
acquisition_json=task.acquisition_json,
# #53 #55: ciclo, chiusura e campi extra seguono il task nella nuova versione. # #53 #55: ciclo, chiusura e campi extra seguono il task nella nuova versione.
in_loop=task.in_loop, in_loop=task.in_loop,
at_close=task.at_close, at_close=task.at_close,
+122 -14
View File
@@ -231,35 +231,36 @@ CAL_NOT_CALIBRATED = "not_calibrated" # l'acquisitore risponde 404 (o un valore
CAL_UNREACHABLE = "unreachable" # l'acquisitore non risponde CAL_UNREACHABLE = "unreachable" # l'acquisitore non risponde
async def fetch_station_calibration(station: Station | None) -> tuple[dict | None, str]: async def fetch_calibration(
"""Legge la calibrazione dall'acquisitore della stazione (C1, #83). 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 GET {camera_url}/camera/calibration?source={camera_source}. La calibrazione
vive nell'acquisitore: TMF la legge, non la tiene. Ritorna (dict, "ok") vive nell'acquisitore: TMF la legge, non la tiene. Ritorna (dict, "ok")
oppure (None, motivo) per distinguere «non calibrata» da «irraggiungibile». oppure (None, motivo) per distinguere «non calibrata» da «irraggiungibile».
Un px_per_mm non finito o <= 0 conta come non calibrata. Un px_per_mm non finito o <= 0 conta come non calibrata. `label` serve
solo ai log (stazione o task).
""" """
if station is None or not station.camera_url or not station.camera_source: if not camera_url or not camera_source:
return None, CAL_NO_CAMERA return None, CAL_NO_CAMERA
url = f"{station.camera_url.rstrip('/')}/camera/calibration" url = f"{camera_url.rstrip('/')}/camera/calibration"
try: try:
# timeout breve: e' una lettura di servizio, non deve bloccare l'execute # timeout breve: e' una lettura di servizio, non deve bloccare l'execute
async with httpx.AsyncClient(timeout=5.0) as client: async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(url, params={"source": station.camera_source}) response = await client.get(url, params={"source": camera_source})
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
logger.warning("acquisitore della stazione %s non raggiungibile: %s", station.code, exc) logger.warning("acquisitore %s non raggiungibile: %s", label, exc)
return None, CAL_UNREACHABLE return None, CAL_UNREACHABLE
if response.status_code == status.HTTP_404_NOT_FOUND: if response.status_code == status.HTTP_404_NOT_FOUND:
return None, CAL_NOT_CALIBRATED return None, CAL_NOT_CALIBRATED
if response.status_code != 200: if response.status_code != 200:
logger.warning( logger.warning("acquisitore %s: HTTP %s", label, response.status_code)
"acquisitore della stazione %s: HTTP %s", station.code, response.status_code,
)
return None, CAL_UNREACHABLE return None, CAL_UNREACHABLE
try: try:
body = response.json() body = response.json()
except ValueError: except ValueError:
logger.warning("acquisitore della stazione %s: risposta non JSON", station.code) logger.warning("acquisitore %s: risposta non JSON", label)
return None, CAL_UNREACHABLE return None, CAL_UNREACHABLE
px_per_mm = body.get("px_per_mm") if isinstance(body, dict) else None 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): if not (_is_measurable(px_per_mm) and px_per_mm > 0):
@@ -267,6 +268,33 @@ async def fetch_station_calibration(station: Station | None) -> tuple[dict | Non
return body, CAL_OK 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: async def get_station_calibration(station: Station | None) -> dict | None:
"""La calibrazione C1 della stazione, o None in ogni altro caso (#83).""" """La calibrazione C1 della stazione, o None in ogni altro caso (#83)."""
calibration, _ = await fetch_station_calibration(station) calibration, _ = await fetch_station_calibration(station)
@@ -498,14 +526,15 @@ async def execute_task(
) )
# Worker della stazione della sessione di produzione (#45); senza run o senza URL: centrale # Worker della stazione della sessione di produzione (#45); senza run o senza URL: centrale
station_url = None station_url = None
# Calibrazione letta dall'acquisitore della stazione (#83); None = non c'e' # Calibrazione letta dall'acquisitore (#83); None = non c'e'. #97: la camera e'
calibration = None # quella del task se la ricetta ne sceglie una, altrimenti quella della stazione.
station = None
if run is not None: if run is not None:
station = (await db.execute( station = (await db.execute(
select(Station).where(Station.id == run.station_id) select(Station).where(Station.id == run.station_id)
)).scalar_one_or_none() )).scalar_one_or_none()
station_url = (station.vision_worker_url or None) if station is not None else None station_url = (station.vision_worker_url or None) if station is not None else None
calibration = await get_station_calibration(station) calibration, _ = await fetch_task_calibration(task, station)
if not task.vision_json: if not task.vision_json:
raise HTTPException( raise HTTPException(
@@ -623,6 +652,85 @@ async def execute_task(
return result, saved, report 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) restano fuori da `measurements` e 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"]
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],
}
for q in quotes if _is_measurable(outputs.get(q.vision_output))
]
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: def _reference_file_path(reference: VisionReferenceImage) -> Path:
return settings.upload_path / reference.path return settings.upload_path / reference.path