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