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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user