diff --git a/src/backend/api/routers/tasks.py b/src/backend/api/routers/tasks.py index 8a23988..bfac75c 100644 --- a/src/backend/api/routers/tasks.py +++ b/src/backend/api/routers/tasks.py @@ -250,6 +250,8 @@ async def create_task( file_type=data.file_type, annotations_json=data.annotations_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 in_loop=data.in_loop if data.in_loop is not None else default_in_loop(data.task_type), at_close=data.at_close, diff --git a/src/backend/api/routers/vision.py b/src/backend/api/routers/vision.py index e339e4a..45f1c7c 100644 --- a/src/backend/api/routers/vision.py +++ b/src/backend/api/routers/vision.py @@ -23,7 +23,7 @@ from src.backend.api.routers.files import ( from src.backend.config import settings from src.backend.database import get_db from src.backend.models.api.vision import ( - VisionExecuteResponse, VisionMeasurementResult, VisionRecipeResponse, + VisionCheckResponse, VisionExecuteResponse, VisionMeasurementResult, VisionRecipeResponse, VisionRecipeUploadResponse, VisionReferenceImageResponse, ) 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") async def get_result_image( result_id: int, diff --git a/src/backend/migrations/versions/021_task_acquisition.py b/src/backend/migrations/versions/021_task_acquisition.py new file mode 100644 index 0000000..b298f0e --- /dev/null +++ b/src/backend/migrations/versions/021_task_acquisition.py @@ -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') diff --git a/src/backend/models/api/task.py b/src/backend/models/api/task.py index 035c903..5e182e2 100644 --- a/src/backend/models/api/task.py +++ b/src/backend/models/api/task.py @@ -2,8 +2,9 @@ import re 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 # 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 +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): """Schema for creating a task.""" 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)$") annotations_json: Optional[dict[str, Any]] = None vision_json: Optional[dict] = None + acquisition_json: Optional[AcquisitionConfig] = None # #97 # #53: assente = default dal tipo (misure e check nel ciclo) in_loop: Optional[bool] = None at_close: bool = False @@ -186,6 +212,7 @@ class TaskUpdate(BaseModel): file_type: Optional[str] = Field(None, pattern="^(image|pdf)$") annotations_json: Optional[dict[str, Any]] = None vision_json: Optional[dict] = None + acquisition_json: Optional[AcquisitionConfig] = None # #97 in_loop: Optional[bool] = None at_close: Optional[bool] = None fields: Optional[list[dict[str, Any]]] = None @@ -207,6 +234,7 @@ class TaskResponse(BaseModel): file_type: Optional[str] = None annotations_json: Optional[dict[str, Any]] = None vision_json: Optional[dict] = None + acquisition_json: Optional[AcquisitionConfig] = None # #97 in_loop: bool = False at_close: bool = False fields: Optional[list[dict[str, Any]]] = None diff --git a/src/backend/models/api/vision.py b/src/backend/models/api/vision.py index d1d1782..155f78e 100644 --- a/src/backend/models/api/vision.py +++ b/src/backend/models/api/vision.py @@ -24,6 +24,16 @@ class VisionExecuteResponse(BaseModel): 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): """One numeric output a recipe offers to the quotes.""" key: str diff --git a/src/backend/models/orm/task.py b/src/backend/models/orm/task.py index ff23946..0bdfc9a 100644 --- a/src/backend/models/orm/task.py +++ b/src/backend/models/orm/task.py @@ -65,6 +65,9 @@ class RecipeTask(Base): # Cartella immutabile (relativa a upload_path) con recipe.json e i file di # meta della ricetta VisionSuite v2 (#46). 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 # check si', gli altri no; un task 'note' si puo' mettere nel ciclo a mano). in_loop: Mapped[bool] = mapped_column( diff --git a/src/backend/services/recipe_service.py b/src/backend/services/recipe_service.py index 4ed7afe..d730bfd 100644 --- a/src/backend/services/recipe_service.py +++ b/src/backend/services/recipe_service.py @@ -117,6 +117,8 @@ async def _copy_tasks_to_version( vision_json=task.vision_json, # Cartella immutabile: basta il riferimento (#46). 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. in_loop=task.in_loop, at_close=task.at_close, diff --git a/src/backend/services/vision_service.py b/src/backend/services/vision_service.py index 87ea2ac..5f7f332 100644 --- a/src/backend/services/vision_service.py +++ b/src/backend/services/vision_service.py @@ -231,35 +231,36 @@ CAL_NOT_CALIBRATED = "not_calibrated" # l'acquisitore risponde 404 (o un valore CAL_UNREACHABLE = "unreachable" # l'acquisitore non risponde -async def fetch_station_calibration(station: Station | None) -> tuple[dict | None, str]: - """Legge la calibrazione dall'acquisitore della stazione (C1, #83). +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. + 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 - url = f"{station.camera_url.rstrip('/')}/camera/calibration" + 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": station.camera_source}) + response = await client.get(url, params={"source": camera_source}) 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 if response.status_code == status.HTTP_404_NOT_FOUND: return None, CAL_NOT_CALIBRATED if response.status_code != 200: - logger.warning( - "acquisitore della stazione %s: HTTP %s", station.code, response.status_code, - ) + logger.warning("acquisitore %s: HTTP %s", label, response.status_code) return None, CAL_UNREACHABLE try: body = response.json() 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 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): @@ -267,6 +268,33 @@ async def fetch_station_calibration(station: Station | None) -> tuple[dict | Non 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) @@ -498,14 +526,15 @@ async def execute_task( ) # Worker della stazione della sessione di produzione (#45); senza run o senza URL: centrale station_url = None - # Calibrazione letta dall'acquisitore della stazione (#83); None = non c'e' - calibration = 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 get_station_calibration(station) + calibration, _ = await fetch_task_calibration(task, station) if not task.vision_json: raise HTTPException( @@ -623,6 +652,85 @@ async def execute_task( 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: return settings.upload_path / reference.path