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:
@@ -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