Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4791c34205 | |||
| e582df7962 | |||
| a7b91b5508 | |||
| addf5a2598 | |||
| bdb1e8c8b9 | |||
| ebaafbe6e9 | |||
| 351153e78a | |||
| 842ceca007 | |||
| b78f2422d8 | |||
| 444dc0a6d1 | |||
| 27d7c1e832 | |||
| 4b1b23cfb6 | |||
| 4b57efc73c | |||
| 7c03f53000 | |||
| 67337fa232 | |||
| 661b782bad | |||
| b98d1f15f4 | |||
| 8f9731839a |
@@ -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
|
||||||
@@ -40,14 +40,22 @@ _IMAGE_EXTENSIONS = {
|
|||||||
"image/png": "png",
|
"image/png": "png",
|
||||||
"image/gif": "gif",
|
"image/gif": "gif",
|
||||||
"image/webp": "webp",
|
"image/webp": "webp",
|
||||||
|
"image/bmp": "bmp", # #99
|
||||||
|
"image/x-ms-bmp": "bmp", # #99
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# #99: the Opto bench cameras save BMP and the worker decodes it (PIL), so the
|
||||||
|
# vision routes accept it on top of files.py's list. files.py is untouched: its
|
||||||
|
# attachments are served by extension and BMP is not meant to be shown there.
|
||||||
|
_VISION_IMAGE_TYPES = ALLOWED_IMAGE_TYPES | {"image/bmp", "image/x-ms-bmp"}
|
||||||
|
|
||||||
# Sniffed from the first bytes, independent of the declared Content-Type: a
|
# Sniffed from the first bytes, independent of the declared Content-Type: a
|
||||||
# text file uploaded as "image/png" passes the header check above and must
|
# text file uploaded as "image/png" passes the header check above and must
|
||||||
# still be caught before it reaches the worker as a bare 500 (#15). BMP and
|
# still be caught before it reaches the worker as a bare 500 (#15). TIFF is
|
||||||
# TIFF are included even though `_IMAGE_EXTENSIONS` does not accept them,
|
# included even though the allow-list does not accept it (PIL, and so the
|
||||||
# because PIL - and so the worker - can decode both; this check only refuses
|
# worker, can decode it; browsers do not show it in the result); BMP is now
|
||||||
# what is not a real image at all, it does not narrow the allow-list above.
|
# also allowed (#99). This check only refuses what is not a real image at all,
|
||||||
|
# it does not narrow the allow-list above.
|
||||||
_IMAGE_SIGNATURES: tuple[tuple[bytes, ...], ...] = (
|
_IMAGE_SIGNATURES: tuple[tuple[bytes, ...], ...] = (
|
||||||
(b"\x89PNG\r\n\x1a\n",), # PNG
|
(b"\x89PNG\r\n\x1a\n",), # PNG
|
||||||
(b"\xff\xd8\xff",), # JPEG
|
(b"\xff\xd8\xff",), # JPEG
|
||||||
@@ -83,7 +91,7 @@ async def _read_image(image: UploadFile) -> bytes:
|
|||||||
is in hand - the same two-step convention `files.py::upload_file` already
|
is in hand - the same two-step convention `files.py::upload_file` already
|
||||||
uses, reused rather than reinvented here.
|
uses, reused rather than reinvented here.
|
||||||
"""
|
"""
|
||||||
if image.content_type not in ALLOWED_IMAGE_TYPES:
|
if image.content_type not in _VISION_IMAGE_TYPES: # #99
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=f"File type {image.content_type} not allowed. Must be an image.",
|
detail=f"File type {image.content_type} not allowed. Must be an image.",
|
||||||
@@ -164,6 +172,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,
|
||||||
@@ -342,6 +366,20 @@ async def preview(
|
|||||||
return await vision_service.preview(db, task, await _read_image(image))
|
return await vision_service.preview(db, task, await _read_image(image))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/dxf-preview")
|
||||||
|
async def dxf_preview(
|
||||||
|
dxf: UploadFile = File(...),
|
||||||
|
current_user: User = Depends(require_maker),
|
||||||
|
) -> dict:
|
||||||
|
"""#101: anteprima del DXF scelto nel wizard «Crea ricetta da DXF» (contorni
|
||||||
|
in mm e dimensioni), letto dal worker con lo stesso lettore del confronto.
|
||||||
|
Non scrive nulla; stesso limite di dimensione della rotta dxf-compare."""
|
||||||
|
dxf_bytes = await dxf.read()
|
||||||
|
if not validate_file_size(len(dxf_bytes)):
|
||||||
|
raise _422([f"the DXF exceeds {settings.max_upload_size_mb}MB"])
|
||||||
|
return await vision_service.dxf_preview(dxf_bytes)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/tasks/{task_id}/recipe/dxf-compare")
|
@router.post("/tasks/{task_id}/recipe/dxf-compare")
|
||||||
async def create_dxf_compare_recipe(
|
async def create_dxf_compare_recipe(
|
||||||
task_id: int,
|
task_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,
|
||||||
|
|||||||
@@ -224,6 +224,25 @@ async def dxf_compare_recipe(
|
|||||||
return body
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
async def dxf_preview(dxf_bytes: bytes, worker_url: str | None = None) -> dict:
|
||||||
|
"""#101: anteprima del DXF (contorni in mm + dimensioni) dal worker. Sostituita dai test."""
|
||||||
|
body = await call_worker_multipart(
|
||||||
|
"/dxf/preview",
|
||||||
|
files={"dxf": ("disegno.dxf", dxf_bytes, "application/octet-stream")},
|
||||||
|
data={},
|
||||||
|
worker_url=worker_url,
|
||||||
|
)
|
||||||
|
# forma minima del contratto: senza contorni e dimensioni non c'e' nulla da mostrare
|
||||||
|
required = {"contorni", "bbox", "larghezza_mm", "altezza_mm", "entita", "punti"}
|
||||||
|
if not required <= body.keys():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail="the vision worker's answer was malformed: missing "
|
||||||
|
+ ", ".join(sorted(required - body.keys())),
|
||||||
|
)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
# Stati di fetch_station_calibration (#83)
|
# Stati di fetch_station_calibration (#83)
|
||||||
CAL_OK = "ok"
|
CAL_OK = "ok"
|
||||||
CAL_NO_CAMERA = "no_camera" # la stazione non ha camera_url/camera_source
|
CAL_NO_CAMERA = "no_camera" # la stazione non ha camera_url/camera_source
|
||||||
@@ -231,35 +250,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 +287,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 +545,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 +671,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
|
||||||
|
|
||||||
|
|||||||
@@ -341,3 +341,22 @@ class TestStationCameraMigration:
|
|||||||
assert "vision_worker_url" in cols("stations") # la 019 resta
|
assert "vision_worker_url" in cols("stations") # la 019 resta
|
||||||
command.upgrade(cfg, "head") # e di nuovo su
|
command.upgrade(cfg, "head") # e di nuovo su
|
||||||
assert {"camera_url", "camera_source"} <= cols("stations")
|
assert {"camera_url", "camera_source"} <= cols("stations")
|
||||||
|
|
||||||
|
|
||||||
|
class TestTaskAcquisitionMigration:
|
||||||
|
"""021_task_acquisition: recipe_tasks.acquisition_json, su e giu (#97)."""
|
||||||
|
|
||||||
|
def test_021_upgrade_and_downgrade(self, sqlite_database_url):
|
||||||
|
db_path, _ = sqlite_database_url
|
||||||
|
cfg = _alembic_config()
|
||||||
|
|
||||||
|
def cols(table):
|
||||||
|
return {c["name"] for c in _inspect(db_path).get_columns(table)}
|
||||||
|
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
assert "acquisition_json" in cols("recipe_tasks")
|
||||||
|
command.downgrade(cfg, "020_station_camera")
|
||||||
|
assert "acquisition_json" not in cols("recipe_tasks")
|
||||||
|
assert "camera_url" in cols("stations") # la 020 resta
|
||||||
|
command.upgrade(cfg, "head") # e di nuovo su
|
||||||
|
assert "acquisition_json" in cols("recipe_tasks")
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
"""#97: camera e modo di analisi nel task, POST /api/vision/check senza salvataggio.
|
||||||
|
|
||||||
|
Il worker e l'acquisitore non girano nei test: si fingono (fixture di
|
||||||
|
test_vision_dxf_compare e test_vision_station_worker).
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from src.backend.config import settings
|
||||||
|
from src.backend.models.orm.measurement import Measurement
|
||||||
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||||
|
from src.backend.models.orm.vision import VisionResult
|
||||||
|
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||||
|
from src.backend.tests.test_vision_copy_on_write import (
|
||||||
|
_current_task_and_subtask, _seed_measurement,
|
||||||
|
)
|
||||||
|
from src.backend.tests.test_vision_dxf_compare import ( # noqa: F401 (fixture acquisitore)
|
||||||
|
CAL, CAMERA_URL, acquisitore, redirect_uploads,
|
||||||
|
)
|
||||||
|
from src.backend.tests.test_vision_execute import an_image
|
||||||
|
from src.backend.tests.test_vision_station_worker import ( # noqa: F401 (fixture worker_calls)
|
||||||
|
_camera_task, _execute, _station_with_run, worker_calls,
|
||||||
|
)
|
||||||
|
|
||||||
|
TASK_CAMERA = "http://10.0.0.50:8300"
|
||||||
|
ACQ = {"camera_url": TASK_CAMERA, "camera_source": "camera:601020", "modo": "in_linea"}
|
||||||
|
|
||||||
|
|
||||||
|
async def _task_with_acquisition(db_session, user_id, acquisition=ACQ):
|
||||||
|
recipe, task = await _camera_task(db_session, user_id)
|
||||||
|
task.acquisition_json = acquisition
|
||||||
|
await db_session.commit()
|
||||||
|
return recipe, task
|
||||||
|
|
||||||
|
|
||||||
|
# --- modello e API del task ---------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_task_stores_and_normalizes_acquisition(client, db_session, maker_user):
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/recipes/{recipe.id}/tasks",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
json={
|
||||||
|
"title": "Camera", "task_type": "camera_measure",
|
||||||
|
"acquisition_json": {"camera_url": " http://h:1/ ", "camera_source": " camera:1 "},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
# url normalizzato come nelle stazioni, sorgente senza spazi, modo di default
|
||||||
|
expected = {"camera_url": "http://h:1", "camera_source": "camera:1", "modo": "impulsiva"}
|
||||||
|
assert body["acquisition_json"] == expected
|
||||||
|
db_session.expunge_all()
|
||||||
|
task = (await db_session.execute(
|
||||||
|
select(RecipeTask).where(RecipeTask.id == body["id"])
|
||||||
|
)).scalar_one()
|
||||||
|
assert task.acquisition_json == expected # sul DB, non solo nella risposta
|
||||||
|
got = await client.get(f"/api/tasks/{body['id']}", headers=auth_headers(maker_user))
|
||||||
|
assert got.json()["acquisition_json"] == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_task_without_acquisition_is_null(client, db_session, maker_user):
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||||
|
resp = await client.get(f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user))
|
||||||
|
assert resp.json()[0]["acquisition_json"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("bad", [
|
||||||
|
{"camera_url": "ftp://h", "camera_source": "camera:1"},
|
||||||
|
{"camera_url": " ", "camera_source": "camera:1"},
|
||||||
|
{"camera_url": "http://h", "camera_source": " "},
|
||||||
|
{"camera_url": "http://h", "camera_source": "x" * 201},
|
||||||
|
{"camera_url": "http://h", "camera_source": "camera:1", "modo": "continua"},
|
||||||
|
{"camera_source": "camera:1"},
|
||||||
|
])
|
||||||
|
async def test_invalid_acquisition_is_422(client, db_session, maker_user, bad):
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/recipes/{recipe.id}/tasks",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
json={"title": "Camera", "task_type": "camera_measure", "acquisition_json": bad},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_sets_and_clears_acquisition(client, db_session, maker_user):
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||||
|
_, task, _ = await _current_task_and_subtask(db_session, recipe.id)
|
||||||
|
resp = await client.put(
|
||||||
|
f"/api/tasks/{task.id}", headers=auth_headers(maker_user), json={"acquisition_json": ACQ},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["acquisition_json"] == ACQ
|
||||||
|
resp = await client.put(
|
||||||
|
f"/api/tasks/{task.id}", headers=auth_headers(maker_user), json={"acquisition_json": None},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["acquisition_json"] is None # null = camera della stazione
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_copy_on_write_preserves_acquisition(client, db_session, maker_user):
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||||
|
_, task, _ = await _current_task_and_subtask(db_session, recipe.id)
|
||||||
|
task.acquisition_json = ACQ
|
||||||
|
await db_session.flush()
|
||||||
|
await _seed_measurement(db_session, recipe.id, maker_user.id)
|
||||||
|
db_session.expunge_all()
|
||||||
|
|
||||||
|
resp = await client.put(
|
||||||
|
f"/api/recipes/{recipe.id}", headers=auth_headers(maker_user),
|
||||||
|
json={"change_notes": "forza il copy-on-write"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
db_session.expunge_all()
|
||||||
|
version, new_task, _ = await _current_task_and_subtask(db_session, recipe.id)
|
||||||
|
assert version.version_number == 2
|
||||||
|
assert new_task.acquisition_json == ACQ
|
||||||
|
|
||||||
|
|
||||||
|
# --- calibrazione dalla camera del task ----------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_reads_calibration_from_the_task_camera(
|
||||||
|
client, db_session, measurement_tec_user, worker_calls, acquisitore,
|
||||||
|
):
|
||||||
|
recipe, task = await _task_with_acquisition(db_session, measurement_tec_user.id)
|
||||||
|
# stazione con camera propria: la camera del task prevale
|
||||||
|
station, run = await _station_with_run(db_session, measurement_tec_user, recipe, None)
|
||||||
|
station.camera_url, station.camera_source = CAMERA_URL, "camera:A1"
|
||||||
|
await db_session.commit()
|
||||||
|
await _execute(client, measurement_tec_user, task, run)
|
||||||
|
assert acquisitore["urls"] == [(f"{TASK_CAMERA}/camera/calibration", {"source": "camera:601020"})]
|
||||||
|
assert worker_calls[0]["calibration"] == {"px_per_mm": CAL["px_per_mm"]}
|
||||||
|
result = (await db_session.execute(select(VisionResult))).scalars().one()
|
||||||
|
assert result.calibration_snapshot["px_per_mm"] == CAL["px_per_mm"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_task_camera_works_without_a_run(
|
||||||
|
client, db_session, measurement_tec_user, worker_calls, acquisitore,
|
||||||
|
):
|
||||||
|
_, task = await _task_with_acquisition(db_session, measurement_tec_user.id)
|
||||||
|
await _execute(client, measurement_tec_user, task) # niente sessione di produzione
|
||||||
|
assert acquisitore["urls"][0][0] == f"{TASK_CAMERA}/camera/calibration"
|
||||||
|
assert "calibration" in worker_calls[0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_falls_back_to_the_station_camera(
|
||||||
|
client, db_session, measurement_tec_user, worker_calls, acquisitore,
|
||||||
|
):
|
||||||
|
recipe, task = await _camera_task(db_session, measurement_tec_user.id) # acquisition_json null
|
||||||
|
station, run = await _station_with_run(db_session, measurement_tec_user, recipe, None)
|
||||||
|
station.camera_url, station.camera_source = CAMERA_URL, "camera:A1"
|
||||||
|
await db_session.commit()
|
||||||
|
await _execute(client, measurement_tec_user, task, run)
|
||||||
|
assert acquisitore["urls"] == [(f"{CAMERA_URL}/camera/calibration", {"source": "camera:A1"})]
|
||||||
|
assert worker_calls[0]["calibration"] == {"px_per_mm": CAL["px_per_mm"]}
|
||||||
|
|
||||||
|
|
||||||
|
# --- POST /api/vision/check ----------------------------------------------------
|
||||||
|
|
||||||
|
async def _check(client, user, task, run=None):
|
||||||
|
data = {"task_id": str(task.id)}
|
||||||
|
if run is not None:
|
||||||
|
data["production_run_id"] = str(run.id)
|
||||||
|
return await client.post(
|
||||||
|
"/api/vision/check", headers=auth_headers(user),
|
||||||
|
files={"image": ("p.png", an_image(), "image/png")}, data=data,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _counts(db_session):
|
||||||
|
return tuple([
|
||||||
|
(await db_session.execute(select(func.count()).select_from(model))).scalar_one()
|
||||||
|
for model in (Measurement, VisionResult)
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_saves_nothing_and_reports_pass_fail(
|
||||||
|
client, db_session, measurement_tec_user, monkeypatch, acquisitore,
|
||||||
|
):
|
||||||
|
_, task = await _task_with_acquisition(db_session, measurement_tec_user.id)
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
async def fake_call(image_bytes, graph, **kwargs):
|
||||||
|
seen.update(kwargs)
|
||||||
|
return {"outputs": {"t1.diameter": 11.0}, "failures": [{"tool_name": "t"}],
|
||||||
|
"overlay": [{"kind": "line"}], "outcome": "fail",
|
||||||
|
"engine_version": "e" * 40, "duration_ms": 3}
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call)
|
||||||
|
before = await _counts(db_session)
|
||||||
|
|
||||||
|
resp = await _check(client, measurement_tec_user, task)
|
||||||
|
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
quote = (await db_session.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
||||||
|
)).scalars().one()
|
||||||
|
assert body == {
|
||||||
|
"outcome": "fail", "overlay": [{"kind": "line"}], "failures": [{"tool_name": "t"}],
|
||||||
|
"measurements": [{"subtask_id": quote.id, "value": 11.0, "pass_fail": "fail"}],
|
||||||
|
"calibration": "ok",
|
||||||
|
}
|
||||||
|
assert seen["calibration"] == {"px_per_mm": CAL["px_per_mm"]} # stessa calibrazione di execute
|
||||||
|
assert await _counts(db_session) == before # niente misure ne' VisionResult
|
||||||
|
assert not list(settings.upload_path.rglob("*.overlay.json")) # ne' file
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("value,expected", [(10.0, "pass"), (10.4, "warning"), (9.0, "fail")])
|
||||||
|
async def test_check_pass_fail_levels(
|
||||||
|
client, db_session, measurement_tec_user, monkeypatch, value, expected,
|
||||||
|
):
|
||||||
|
_, task = await _camera_task(db_session, measurement_tec_user.id)
|
||||||
|
|
||||||
|
async def fake_call(image_bytes, graph, **kwargs):
|
||||||
|
return {"outputs": {"t1.diameter": value}, "failures": [], "engine_version": "e" * 40}
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call)
|
||||||
|
resp = await _check(client, measurement_tec_user, task)
|
||||||
|
assert resp.json()["measurements"][0]["pass_fail"] == expected
|
||||||
|
assert resp.json()["calibration"] == "no_camera" # senza camera ne' stazione
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_calibration_reasons(
|
||||||
|
client, db_session, measurement_tec_user, worker_calls, acquisitore,
|
||||||
|
):
|
||||||
|
_, task = await _task_with_acquisition(db_session, measurement_tec_user.id)
|
||||||
|
acquisitore["cal"] = None
|
||||||
|
assert (await _check(client, measurement_tec_user, task)).json()["calibration"] == "not_calibrated"
|
||||||
|
acquisitore["cal"] = "down"
|
||||||
|
assert (await _check(client, measurement_tec_user, task)).json()["calibration"] == "unreachable"
|
||||||
|
assert "calibration" not in worker_calls[-1] # senza calibrazione la ricetta usa la sua
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_omits_quotes_the_graph_did_not_produce(
|
||||||
|
client, db_session, measurement_tec_user, monkeypatch,
|
||||||
|
):
|
||||||
|
_, task = await _camera_task(db_session, measurement_tec_user.id)
|
||||||
|
|
||||||
|
async def fake_call(image_bytes, graph, **kwargs):
|
||||||
|
return {"outputs": {}, "failures": [{"tool_name": "blob"}], "engine_version": "e" * 40}
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call)
|
||||||
|
resp = await _check(client, measurement_tec_user, task)
|
||||||
|
assert resp.status_code == 200 # un'anteprima non da' 422 a ogni frame
|
||||||
|
assert resp.json()["measurements"] == []
|
||||||
|
assert resp.json()["failures"] == [{"tool_name": "blob"}]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_422_without_graph_or_quotes_or_vision_type(
|
||||||
|
client, db_session, measurement_tec_user, worker_calls,
|
||||||
|
):
|
||||||
|
_, task = await _camera_task(db_session, measurement_tec_user.id)
|
||||||
|
task.vision_json = None
|
||||||
|
await db_session.commit()
|
||||||
|
assert (await _check(client, measurement_tec_user, task)).status_code == 422
|
||||||
|
|
||||||
|
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
||||||
|
quote = (await db_session.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
||||||
|
)).scalars().one()
|
||||||
|
quote.vision_output = None
|
||||||
|
await db_session.commit()
|
||||||
|
assert (await _check(client, measurement_tec_user, task)).status_code == 422
|
||||||
|
|
||||||
|
task.task_type = "measure"
|
||||||
|
await db_session.commit()
|
||||||
|
assert (await _check(client, measurement_tec_user, task)).status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_with_run_uses_station_worker_and_station_camera(
|
||||||
|
client, db_session, measurement_tec_user, worker_calls, acquisitore,
|
||||||
|
):
|
||||||
|
recipe, task = await _camera_task(db_session, measurement_tec_user.id)
|
||||||
|
station, run = await _station_with_run(
|
||||||
|
db_session, measurement_tec_user, recipe, "http://10.0.0.7:8100")
|
||||||
|
station.camera_url, station.camera_source = CAMERA_URL, "camera:A1"
|
||||||
|
await db_session.commit()
|
||||||
|
before = await _counts(db_session)
|
||||||
|
resp = await _check(client, measurement_tec_user, task, run)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert worker_calls[0]["worker_url"] == "http://10.0.0.7:8100"
|
||||||
|
assert acquisitore["urls"][0][0] == f"{CAMERA_URL}/camera/calibration"
|
||||||
|
assert await _counts(db_session) == before
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_requires_login(client, db_session, measurement_tec_user, worker_calls):
|
||||||
|
_, task = await _camera_task(db_session, measurement_tec_user.id)
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/vision/check", files={"image": ("p.png", an_image(), "image/png")},
|
||||||
|
data={"task_id": str(task.id)},
|
||||||
|
)
|
||||||
|
assert resp.status_code in (401, 403)
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""#99: the vision routes accept BMP (the Opto bench cameras save it); TIFF and
|
||||||
|
non-images stay refused. files.py (attachments) is not touched by this."""
|
||||||
|
import io
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from src.backend.config import settings
|
||||||
|
from src.backend.models.orm.vision import VisionReferenceImage
|
||||||
|
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||||
|
from src.backend.tests.test_vision_upload_limits import ( # noqa: F401 - fixtures
|
||||||
|
recipe_parts, redirect_uploads, worker_says,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def a_bmp() -> bytes:
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
Image.new("L", (64, 64), color=0).save(buffer, format="BMP")
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def a_tiff() -> bytes:
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
Image.new("L", (64, 64), color=0).save(buffer, format="TIFF")
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
async def a_camera_task(db_session, user):
|
||||||
|
recipe = await create_test_recipe(db_session, user.id)
|
||||||
|
_, task, _ = await recipe_parts(db_session, recipe.id)
|
||||||
|
task.task_type = "camera_measure"
|
||||||
|
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
||||||
|
await db_session.commit()
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("content_type", ["image/bmp", "image/x-ms-bmp"])
|
||||||
|
async def test_preview_accepts_a_bmp(
|
||||||
|
client, db_session, maker_user, worker_says, content_type,
|
||||||
|
):
|
||||||
|
task = await a_camera_task(db_session, maker_user)
|
||||||
|
worker_says({"t1.diameter": 10.0})
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/vision/preview",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
files={"image": ("p.bmp", a_bmp(), content_type)},
|
||||||
|
data={"task_id": str(task.id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code != 400, response.text
|
||||||
|
assert response.status_code != 422, response.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_preview_still_rejects_a_tiff(
|
||||||
|
client, db_session, maker_user, worker_says,
|
||||||
|
):
|
||||||
|
task = await a_camera_task(db_session, maker_user)
|
||||||
|
worker_says({"t1.diameter": 10.0})
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/vision/preview",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
files={"image": ("p.tif", a_tiff(), "image/tiff")},
|
||||||
|
data={"task_id": str(task.id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_text_file_declared_bmp_is_refused_on_its_bytes(
|
||||||
|
client, db_session, maker_user, worker_says,
|
||||||
|
):
|
||||||
|
task = await a_camera_task(db_session, maker_user)
|
||||||
|
worker_says({"t1.diameter": 10.0})
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/vision/preview",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
files={"image": ("p.bmp", b"i am not an image", "image/bmp")},
|
||||||
|
data={"task_id": str(task.id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reference_image_bmp_is_saved_with_the_bmp_extension(
|
||||||
|
client, db_session, maker_user,
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||||
|
_, task, _ = await recipe_parts(db_session, recipe.id)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/vision/reference-images",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
files={"image": ("p.bmp", a_bmp(), "image/x-ms-bmp")},
|
||||||
|
data={"task_id": str(task.id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
saved = (await db_session.execute(select(VisionReferenceImage))).scalars().one()
|
||||||
|
assert saved.path.endswith(".bmp")
|
||||||
|
assert (settings.upload_path / saved.path).exists()
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""#101: POST /api/vision/dxf-preview, anteprima del DXF nel wizard.
|
||||||
|
|
||||||
|
Il worker non gira nei test: `vision_service.dxf_preview` si sostituisce.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from src.backend.config import settings
|
||||||
|
from src.backend.services import vision_service
|
||||||
|
from src.backend.tests.conftest import auth_headers
|
||||||
|
|
||||||
|
BODY = {
|
||||||
|
"contorni": [[[0.0, 0.0], [10.0, 0.0], [10.0, 5.0], [0.0, 5.0], [0.0, 0.0]]],
|
||||||
|
"bbox": {"xmin": 0.0, "ymin": 0.0, "xmax": 10.0, "ymax": 5.0},
|
||||||
|
"larghezza_mm": 10.0, "altezza_mm": 5.0, "entita": 1, "punti": 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _post(client, user, content=b"0\nEOF"):
|
||||||
|
return client.post(
|
||||||
|
"/api/vision/dxf-preview",
|
||||||
|
headers=auth_headers(user),
|
||||||
|
files={"dxf": ("d.dxf", content, "application/octet-stream")},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_preview_forwards_the_file_and_returns_the_worker_body(
|
||||||
|
client, maker_user, monkeypatch,
|
||||||
|
):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
async def fake(dxf_bytes, worker_url=None):
|
||||||
|
calls.append(dxf_bytes)
|
||||||
|
return BODY
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.backend.services.vision_service.dxf_preview", fake)
|
||||||
|
resp = await _post(client, maker_user, b"DXF-BYTES")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json() == BODY
|
||||||
|
assert calls == [b"DXF-BYTES"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_preview_forwards_the_worker_422(client, maker_user, monkeypatch):
|
||||||
|
async def fake(dxf_bytes, worker_url=None):
|
||||||
|
raise HTTPException(status_code=422, detail="DXF illeggibile: file vuoto")
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.backend.services.vision_service.dxf_preview", fake)
|
||||||
|
resp = await _post(client, maker_user, b"")
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"] == "DXF illeggibile: file vuoto"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_preview_requires_maker(client, measurement_tec_user, monkeypatch):
|
||||||
|
async def fake(dxf_bytes, worker_url=None):
|
||||||
|
return BODY
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.backend.services.vision_service.dxf_preview", fake)
|
||||||
|
resp = await _post(client, measurement_tec_user)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_preview_too_big_is_422(client, maker_user, monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "max_upload_size_mb", 0)
|
||||||
|
resp = await _post(client, maker_user, b"x" * 10)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_preview_service_posts_to_worker_and_checks_shape(monkeypatch):
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
async def fake_multipart(path, *, files, data, worker_url=None, timeout=120.0):
|
||||||
|
seen.update(path=path, files=files)
|
||||||
|
return BODY
|
||||||
|
|
||||||
|
monkeypatch.setattr(vision_service, "call_worker_multipart", fake_multipart)
|
||||||
|
assert await vision_service.dxf_preview(b"abc") == BODY
|
||||||
|
assert seen["path"] == "/dxf/preview" and seen["files"]["dxf"][1] == b"abc"
|
||||||
|
|
||||||
|
async def malformed(path, *, files, data, worker_url=None, timeout=120.0):
|
||||||
|
return {"contorni": []}
|
||||||
|
|
||||||
|
monkeypatch.setattr(vision_service, "call_worker_multipart", malformed)
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
await vision_service.dxf_preview(b"abc")
|
||||||
|
assert exc.value.status_code == 502
|
||||||
@@ -701,6 +701,25 @@ def api_dxf_compare_recipe(task_id: int):
|
|||||||
return jsonify(resp), 200
|
return jsonify(resp), 200
|
||||||
|
|
||||||
|
|
||||||
|
# #101: anteprima del DXF appena scelto nel wizard (contorni letti dal vision worker)
|
||||||
|
@maker_bp.route("/api/vision/dxf-preview", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@role_required("Maker")
|
||||||
|
def api_dxf_preview():
|
||||||
|
"""Proxy: anteprima del DXF (contorni, bbox, L x H) prima di creare la ricetta (#101)."""
|
||||||
|
dxf = request.files.get("dxf")
|
||||||
|
if dxf is None or not dxf.filename:
|
||||||
|
return jsonify({"error": True, "detail": _("Serve il file DXF")}), 400
|
||||||
|
|
||||||
|
resp = api_client.post(
|
||||||
|
"/api/vision/dxf-preview",
|
||||||
|
files={"dxf": (dxf.filename, dxf.read(), dxf.mimetype or "application/octet-stream")},
|
||||||
|
)
|
||||||
|
if resp.get("error"):
|
||||||
|
return jsonify(resp), resp.get("status_code", 500)
|
||||||
|
return jsonify(resp), 200
|
||||||
|
|
||||||
|
|
||||||
@maker_bp.route("/api/files/<path:file_path>", methods=["GET"])
|
@maker_bp.route("/api/files/<path:file_path>", methods=["GET"])
|
||||||
@login_required
|
@login_required
|
||||||
def api_get_file(file_path: str):
|
def api_get_file(file_path: str):
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""MeasurementTec blueprint - recipe selection and measurement execution."""
|
"""MeasurementTec blueprint - recipe selection and measurement execution."""
|
||||||
|
import json
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
import requests as http_requests
|
import requests as http_requests
|
||||||
@@ -337,6 +338,14 @@ def task_execute(task_id: int):
|
|||||||
measurement_interval_minutes = recipe_resp.get("measurement_interval_minutes")
|
measurement_interval_minutes = recipe_resp.get("measurement_interval_minutes")
|
||||||
allow_manual_input = bool(recipe_resp.get("allow_manual_input", True))
|
allow_manual_input = bool(recipe_resp.get("allow_manual_input", True))
|
||||||
|
|
||||||
|
# #97: camera del task (acquisition_json) o della stazione; il modo di analisi
|
||||||
|
# (impulsiva / in_linea) viene dal task. Solo i task visione ne hanno bisogno.
|
||||||
|
camera = None
|
||||||
|
camera_mode = "impulsiva"
|
||||||
|
if task_resp.get("task_type") in ("camera_measure", "dxf_compare"):
|
||||||
|
camera = _task_camera(task_resp, _current_station()[0])
|
||||||
|
camera_mode = _task_mode(task_resp)
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"measure/task_execute.html",
|
"measure/task_execute.html",
|
||||||
task=task_resp,
|
task=task_resp,
|
||||||
@@ -345,7 +354,8 @@ def task_execute(task_id: int):
|
|||||||
all_task_ids=all_task_ids,
|
all_task_ids=all_task_ids,
|
||||||
measurement_interval_minutes=measurement_interval_minutes,
|
measurement_interval_minutes=measurement_interval_minutes,
|
||||||
allow_manual_input=allow_manual_input,
|
allow_manual_input=allow_manual_input,
|
||||||
camera_enabled=_station_camera(_current_station()[0]) is not None,
|
camera_enabled=camera is not None,
|
||||||
|
camera_mode=camera_mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1001,6 +1011,42 @@ def api_vision_execute():
|
|||||||
return jsonify(resp), 200
|
return jsonify(resp), 200
|
||||||
|
|
||||||
|
|
||||||
|
# #97: analysis of a frame without saving anything (preview before «Registra»).
|
||||||
|
@measure_bp.route("/api/vision/check", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@role_required("MeasurementTec")
|
||||||
|
def api_vision_check():
|
||||||
|
"""Proxy: run the task's VisionSuite recipe on the image, nothing is saved."""
|
||||||
|
image = request.files.get("image")
|
||||||
|
task_id = request.form.get("task_id")
|
||||||
|
if image is None or not task_id:
|
||||||
|
return jsonify({
|
||||||
|
"error": True,
|
||||||
|
"detail": _("Dati mancanti: task_id e image sono obbligatori"),
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
form = {"task_id": task_id}
|
||||||
|
if request.form.get("production_run_id"):
|
||||||
|
form["production_run_id"] = request.form["production_run_id"]
|
||||||
|
|
||||||
|
resp = api_client.post(
|
||||||
|
"/api/vision/check",
|
||||||
|
data=form,
|
||||||
|
files={"image": (
|
||||||
|
image.filename or "capture.jpg",
|
||||||
|
image.read(),
|
||||||
|
image.mimetype or "application/octet-stream",
|
||||||
|
)},
|
||||||
|
)
|
||||||
|
if resp.get("error"):
|
||||||
|
status_code = resp.get("status_code", 500)
|
||||||
|
body = {"error": True, "detail": resp.get("detail", _("Errore nell'esecuzione"))}
|
||||||
|
if resp.get("session_expired"):
|
||||||
|
body["session_expired"] = True
|
||||||
|
return jsonify(body), status_code if status_code >= 400 else 500
|
||||||
|
return jsonify(resp), 200
|
||||||
|
|
||||||
|
|
||||||
def _station_camera(station_code: str | None) -> tuple[str, str] | None:
|
def _station_camera(station_code: str | None) -> tuple[str, str] | None:
|
||||||
"""(camera_url, camera_source) of the station, None when it has no camera.
|
"""(camera_url, camera_source) of the station, None when it has no camera.
|
||||||
|
|
||||||
@@ -1019,6 +1065,31 @@ def _station_camera(station_code: str | None) -> tuple[str, str] | None:
|
|||||||
return str(url), str(source)
|
return str(url), str(source)
|
||||||
|
|
||||||
|
|
||||||
|
def _task_acquisition(task: dict | None) -> dict | None:
|
||||||
|
"""#97: acquisition_json of a task as a dict, None when absent or unreadable."""
|
||||||
|
acq = task.get("acquisition_json") if isinstance(task, dict) else None
|
||||||
|
if isinstance(acq, str):
|
||||||
|
try:
|
||||||
|
acq = json.loads(acq)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return acq if isinstance(acq, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _task_camera(task: dict | None, station_code: str | None) -> tuple[str, str] | None:
|
||||||
|
"""#97: camera of a task = its acquisition_json (url + source), else the station's."""
|
||||||
|
acq = _task_acquisition(task)
|
||||||
|
if acq and acq.get("camera_url") and acq.get("camera_source"):
|
||||||
|
return str(acq["camera_url"]), str(acq["camera_source"])
|
||||||
|
return _station_camera(station_code)
|
||||||
|
|
||||||
|
|
||||||
|
def _task_mode(task: dict | None) -> str:
|
||||||
|
"""#97: analysis mode of a task, «impulsiva» unless acquisition_json says «in_linea»."""
|
||||||
|
acq = _task_acquisition(task)
|
||||||
|
return "in_linea" if acq and acq.get("modo") == "in_linea" else "impulsiva"
|
||||||
|
|
||||||
|
|
||||||
# #72: the browser asks the station's camera service (vs-gui) for a frame; the
|
# #72: the browser asks the station's camera service (vs-gui) for a frame; the
|
||||||
# service address is a server-side matter, so Flask relays and the PNG goes back as is.
|
# service address is a server-side matter, so Flask relays and the PNG goes back as is.
|
||||||
@measure_bp.route("/api/vision/grab", methods=["POST"])
|
@measure_bp.route("/api/vision/grab", methods=["POST"])
|
||||||
@@ -1026,7 +1097,16 @@ def _station_camera(station_code: str | None) -> tuple[str, str] | None:
|
|||||||
@role_required("MeasurementTec")
|
@role_required("MeasurementTec")
|
||||||
def api_vision_grab():
|
def api_vision_grab():
|
||||||
"""Proxy: grab one frame from the current station's camera, answer a PNG."""
|
"""Proxy: grab one frame from the current station's camera, answer a PNG."""
|
||||||
camera = _station_camera(_current_station()[0])
|
# #97: con task_id (form o json) la camera e' quella del task, altrimenti della stazione
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
task_id = request.form.get("task_id") or payload.get("task_id")
|
||||||
|
task = None
|
||||||
|
if task_id:
|
||||||
|
try:
|
||||||
|
task = api_client.get(f"/api/tasks/{int(task_id)}")
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
task = None
|
||||||
|
camera = _task_camera(task, _current_station()[0])
|
||||||
if camera is None:
|
if camera is None:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"error": True,
|
"error": True,
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
|
|||||||
imageName: '',
|
imageName: '',
|
||||||
imageSrc: '',
|
imageSrc: '',
|
||||||
dxfFile: null,
|
dxfFile: null,
|
||||||
|
dxfPreview: null, // #101: risposta di /dxf-preview {contorni, bbox, larghezza_mm, altezza_mm, entita, punti}
|
||||||
|
dxfPreviewBusy: false,
|
||||||
|
dxfPreviewError: '',
|
||||||
|
_dxfPreviewSeq: 0, // #101: una risposta arrivata dopo un cambio di file si scarta
|
||||||
|
mmPx: '', // #98: mm/px a mano (immagine da file), stringa con virgola o punto
|
||||||
bandMm: 0.1,
|
bandMm: 0.1,
|
||||||
minPointsPct: 95,
|
minPointsPct: 95,
|
||||||
dark: true, // spuntato = pezzo scuro su fondo chiaro (controluce)
|
dark: true, // spuntato = pezzo scuro su fondo chiaro (controluce)
|
||||||
@@ -59,8 +64,19 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
get calibrated() { return this.calState === 'calibrated'; },
|
get calibrated() { return this.calState === 'calibrated'; },
|
||||||
|
// #98: mm/px inserito a mano -> px/mm; null se vuoto o fuori da 0.00001..100 (limiti di /camera/calibrazioni/manuale, #95)
|
||||||
|
get manualPxMm() {
|
||||||
|
const raw = String(this.mmPx || '').trim().replace(',', '.');
|
||||||
|
if (!raw) return null;
|
||||||
|
const v = Number(raw);
|
||||||
|
if (!Number.isFinite(v) || v < 0.00001 || v > 100) return null;
|
||||||
|
return 1 / v;
|
||||||
|
},
|
||||||
|
// #98: compilato ma non valido -> errore in pagina
|
||||||
|
get mmPxInvalid() { return String(this.mmPx || '').trim() !== '' && this.manualPxMm === null; },
|
||||||
get canRun() {
|
get canRun() {
|
||||||
return !!(this.stationId && this.calibrated && this.imageFile && this.dxfFile) && !this.busy;
|
// #98: la scala a mano sostituisce la calibrazione della stazione
|
||||||
|
return !!(this.stationId && (this.calibrated || this.manualPxMm !== null) && this.imageFile && this.dxfFile) && !this.busy;
|
||||||
},
|
},
|
||||||
|
|
||||||
_setImage(file) {
|
_setImage(file) {
|
||||||
@@ -75,7 +91,67 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
|
|||||||
const f = ev.target.files && ev.target.files[0];
|
const f = ev.target.files && ev.target.files[0];
|
||||||
if (f) this._setImage(f);
|
if (f) this._setImage(f);
|
||||||
},
|
},
|
||||||
onDxf(ev) { this.dxfFile = (ev.target.files && ev.target.files[0]) || null; this.result = null; },
|
onDxf(ev) {
|
||||||
|
this.dxfFile = (ev.target.files && ev.target.files[0]) || null;
|
||||||
|
this.result = null;
|
||||||
|
this.loadDxfPreview(); // #101: anteprima appena si sceglie il file
|
||||||
|
},
|
||||||
|
|
||||||
|
// #101: legge il DXF sul vision worker (via Flask e backend) e ne mostra i contorni
|
||||||
|
async loadDxfPreview() {
|
||||||
|
const seq = ++this._dxfPreviewSeq;
|
||||||
|
this.dxfPreview = null;
|
||||||
|
this.dxfPreviewError = '';
|
||||||
|
this.dxfPreviewBusy = false;
|
||||||
|
if (!this.dxfFile) return; // file tolto: tutto azzerato
|
||||||
|
this.dxfPreviewBusy = true;
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('dxf', this.dxfFile, this.dxfFile.name);
|
||||||
|
const resp = await fetch('/maker/api/vision/dxf-preview', {
|
||||||
|
method: 'POST', headers: { 'X-CSRFToken': this._csrf() }, body: fd,
|
||||||
|
});
|
||||||
|
const body = await resp.json().catch(() => ({}));
|
||||||
|
if (seq !== this._dxfPreviewSeq) return; // file cambiato nel frattempo
|
||||||
|
if (!resp.ok || body.error) this.dxfPreviewError = this._errorText(body);
|
||||||
|
else this.dxfPreview = body;
|
||||||
|
} catch (e) {
|
||||||
|
if (seq !== this._dxfPreviewSeq) return;
|
||||||
|
this.dxfPreviewError = this.msgs.requestFailed || String(e);
|
||||||
|
}
|
||||||
|
if (seq === this._dxfPreviewSeq) this.dxfPreviewBusy = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
// #101: una stringa `points` per ogni contorno, per <polyline>
|
||||||
|
dxfPreviewPaths() {
|
||||||
|
const contorni = (this.dxfPreview && this.dxfPreview.contorni) || [];
|
||||||
|
return contorni.filter(c => c && c.length > 1).map(c => c.map(p => p[0] + ',' + p[1]).join(' '));
|
||||||
|
},
|
||||||
|
|
||||||
|
// #101: tutti i contorni in un solo attributo `d` ("M x y L x y ..."): x-for non funziona dentro <svg>
|
||||||
|
dxfPreviewD() {
|
||||||
|
const contorni = (this.dxfPreview && this.dxfPreview.contorni) || [];
|
||||||
|
return contorni.filter(c => c && c.length > 1)
|
||||||
|
.map(c => c.map((p, i) => (i ? 'L ' : 'M ') + p[0] + ' ' + p[1]).join(' ')).join(' ');
|
||||||
|
},
|
||||||
|
|
||||||
|
// #101: viewBox sul bbox con margine 5% (minimo 0.5 mm per lato)
|
||||||
|
dxfPreviewViewBox() {
|
||||||
|
const b = this.dxfPreview && this.dxfPreview.bbox;
|
||||||
|
if (!b) return '0 0 1 1';
|
||||||
|
const w = b.xmax - b.xmin, h = b.ymax - b.ymin;
|
||||||
|
const m = Math.max(0.5, 0.05 * Math.max(w, h));
|
||||||
|
return [b.xmin - m, b.ymin - m, w + 2 * m, h + 2 * m].map(v => +v.toFixed(3)).join(' ');
|
||||||
|
},
|
||||||
|
|
||||||
|
// #101: «L × H mm · N entità», decimale con virgola (2 decimali) secondo la lingua pagina
|
||||||
|
get dxfPreviewSummary() {
|
||||||
|
const d = this.dxfPreview;
|
||||||
|
if (!d) return '';
|
||||||
|
const f = v => Number(v).toLocaleString(document.documentElement.lang || 'it',
|
||||||
|
{ minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
return `${f(d.larghezza_mm)} × ${f(d.altezza_mm)} mm · ${d.entita} ${this.msgs.dxfEntities || 'entities'}`;
|
||||||
|
},
|
||||||
|
|
||||||
// Frame dalla camera della stazione scelta
|
// Frame dalla camera della stazione scelta
|
||||||
async grab() {
|
async grab() {
|
||||||
@@ -120,6 +196,8 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
|
|||||||
fd.append('image', this.imageFile, this.imageFile.name || 'capture.png');
|
fd.append('image', this.imageFile, this.imageFile.name || 'capture.png');
|
||||||
fd.append('dxf', this.dxfFile, this.dxfFile.name);
|
fd.append('dxf', this.dxfFile, this.dxfFile.name);
|
||||||
fd.append('station_id', this.stationId);
|
fd.append('station_id', this.stationId);
|
||||||
|
// #98: scala a mano; il backend la preferisce alla calibrazione della stazione
|
||||||
|
if (this.manualPxMm !== null) fd.append('px_per_mm', String(this.manualPxMm));
|
||||||
fd.append('band_mm', String(this.bandMm));
|
fd.append('band_mm', String(this.bandMm));
|
||||||
fd.append('min_points_pct', String(this.minPointsPct));
|
fd.append('min_points_pct', String(this.minPointsPct));
|
||||||
fd.append('polarity', this.dark ? 'dark' : 'light');
|
fd.append('polarity', this.dark ? 'dark' : 'light');
|
||||||
|
|||||||
@@ -11,6 +11,13 @@
|
|||||||
* taskType, subtasks, labels.
|
* taskType, subtasks, labels.
|
||||||
* Run context (lot, serial, production run) is read lazily through `ctx()`,
|
* Run context (lot, serial, production run) is read lazily through `ctx()`,
|
||||||
* which the template binds to the parent task component.
|
* which the template binds to the parent task component.
|
||||||
|
*
|
||||||
|
* #97: with a camera (cameraAvailable, the task's or the station's) the task opens
|
||||||
|
* on a frame already grabbed and analysed (/vision/check, nothing saved: «preview»).
|
||||||
|
* modo «impulsiva»: «Acquisisci» redoes grab+check. modo «in_linea»: continuous
|
||||||
|
* loop grab+check, one request at a time, standing still while paused / page hidden
|
||||||
|
* / leaving / saving. «Registra» saves the LAST frame shown through /vision/execute.
|
||||||
|
* Extra config: cameraAvailable, modo, checkUrl.
|
||||||
*/
|
*/
|
||||||
function visionExecute() {
|
function visionExecute() {
|
||||||
// Overlay colour per role (VisionSuite roles: reference, roi, found).
|
// Overlay colour per role (VisionSuite roles: reference, roi, found).
|
||||||
@@ -24,6 +31,125 @@ function visionExecute() {
|
|||||||
imageSrc: '', // object URL of the acquired image (shown under the overlay)
|
imageSrc: '', // object URL of the acquired image (shown under the overlay)
|
||||||
ctx: () => ({}), // replaced by the template: {lot, serial, runId}
|
ctx: () => ({}), // replaced by the template: {lot, serial, runId}
|
||||||
|
|
||||||
|
// #97: preview / loop state
|
||||||
|
preview: false, // result on screen is an analysis only, not saved
|
||||||
|
saved: false, // «Registra» done: the saved result replaces the preview
|
||||||
|
lastBlob: null, // last frame shown, the one «Registra» saves
|
||||||
|
paused: false, // operator paused the in-line loop
|
||||||
|
registering: false, // «Registra» in progress: the loop stands still
|
||||||
|
leaving: false, // page is being left: no more requests
|
||||||
|
looping: false, // an in-line loop is running
|
||||||
|
gen: 0, // bumped by «Registra»: answers of older requests are dropped
|
||||||
|
|
||||||
|
// #97: start on a frame already acquired and analysed; hook visibility and exit.
|
||||||
|
init() {
|
||||||
|
if (!this.hasCamera) return;
|
||||||
|
document.addEventListener('visibilitychange', () => { if (!document.hidden) this.kick(); });
|
||||||
|
const leave = () => { this.leaving = true; };
|
||||||
|
window.addEventListener('pagehide', leave);
|
||||||
|
window.addEventListener('beforeunload', leave);
|
||||||
|
// After x-init has bound ctx(): in-line starts the loop, impulsive grabs once.
|
||||||
|
this.$nextTick(() => { if (this.inLinea) this.loop(); else this.grab(); });
|
||||||
|
},
|
||||||
|
|
||||||
|
get hasCamera() { return !!(window.__visionCfg || {}).cameraAvailable; },
|
||||||
|
get inLinea() { return this.hasCamera && (window.__visionCfg || {}).modo === 'in_linea'; },
|
||||||
|
// «Registra» needs a frame on screen that is not saved yet
|
||||||
|
get canRecord() { return !!this.lastBlob && !this.saved && !this.registering; },
|
||||||
|
|
||||||
|
shouldRun() {
|
||||||
|
return this.inLinea && !this.paused && !this.saved && !this.registering
|
||||||
|
&& !this.leaving && !document.hidden;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Page visible again: restart the loop unless the operator paused it.
|
||||||
|
kick() { if (this.shouldRun() && !this.looping) this.loop(); },
|
||||||
|
|
||||||
|
// In-line loop: one request at a time, at least 300 ms between two of them
|
||||||
|
// (longer after an error, so a dead camera is not hammered).
|
||||||
|
async loop() {
|
||||||
|
if (this.looping) return;
|
||||||
|
this.looping = true;
|
||||||
|
try {
|
||||||
|
while (this.shouldRun()) {
|
||||||
|
const ok = await this.grab();
|
||||||
|
await new Promise((r) => setTimeout(r, ok ? 300 : 2000));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.looping = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// «Pausa» / «Riprendi»; after «Registra» only «Riprendi» restarts the loop.
|
||||||
|
togglePause() {
|
||||||
|
if (this.paused || this.saved) {
|
||||||
|
this.paused = false;
|
||||||
|
this.saved = false;
|
||||||
|
this.loop();
|
||||||
|
} else {
|
||||||
|
this.paused = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Analysis only (/vision/check): the frame becomes the preview, nothing is saved.
|
||||||
|
async analyze(blob) {
|
||||||
|
const cfg = window.__visionCfg || {};
|
||||||
|
if (!blob || !cfg.checkUrl) return false;
|
||||||
|
const gen = this.gen;
|
||||||
|
this.busy = true;
|
||||||
|
this.error = '';
|
||||||
|
try {
|
||||||
|
const c = this.ctx() || {};
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('task_id', cfg.taskId);
|
||||||
|
fd.append('image', blob, blob.name || 'capture.jpg');
|
||||||
|
if (c.runId) fd.append('production_run_id', c.runId);
|
||||||
|
const csrf = document.querySelector('meta[name=csrf-token]')?.content || '';
|
||||||
|
const resp = await fetch(cfg.checkUrl, {
|
||||||
|
method: 'POST', headers: { 'X-CSRFToken': csrf }, body: fd,
|
||||||
|
});
|
||||||
|
const body = await resp.json().catch(() => ({}));
|
||||||
|
if (gen !== this.gen) return false; // «Registra» took over
|
||||||
|
if (!resp.ok || body.error) {
|
||||||
|
this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (this.imageSrc) URL.revokeObjectURL(this.imageSrc);
|
||||||
|
this.imageSrc = URL.createObjectURL(blob);
|
||||||
|
this.lastBlob = blob;
|
||||||
|
this.result = body;
|
||||||
|
this.preview = true;
|
||||||
|
this.saved = false;
|
||||||
|
this.$nextTick(() => this.drawOverlay());
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
if (gen === this.gen) this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
if (gen === this.gen) this.busy = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// «Registra»: save the LAST frame shown with the usual /vision/execute.
|
||||||
|
async record() {
|
||||||
|
if (!this.canRecord) return;
|
||||||
|
const blob = this.lastBlob;
|
||||||
|
this.registering = true;
|
||||||
|
this.gen++; // drop whatever request is still in flight
|
||||||
|
this.busy = false;
|
||||||
|
this.grabbing = false;
|
||||||
|
try {
|
||||||
|
await this.acquire(blob);
|
||||||
|
if (!this.error && this.result && this.result.vision_result_id) {
|
||||||
|
this.preview = false;
|
||||||
|
this.saved = true;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.registering = false;
|
||||||
|
this.kick(); // in-line: stays still while saved, restarts if the save failed
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Entry point shared with the future agent camera.
|
// Entry point shared with the future agent camera.
|
||||||
async acquire(blob) {
|
async acquire(blob) {
|
||||||
if (!blob || this.busy) return;
|
if (!blob || this.busy) return;
|
||||||
@@ -64,37 +190,52 @@ function visionExecute() {
|
|||||||
|
|
||||||
// Station camera (#72): the Flask proxy answers a PNG, which goes through
|
// Station camera (#72): the Flask proxy answers a PNG, which goes through
|
||||||
// acquire() like a picked file. Errors show as the execute ones do.
|
// acquire() like a picked file. Errors show as the execute ones do.
|
||||||
|
// #97: sends task_id (the task's camera wins over the station's); with a camera
|
||||||
|
// the frame goes to the preview analysis. Returns true when the frame was analysed.
|
||||||
async grab() {
|
async grab() {
|
||||||
const cfg = window.__visionCfg || {};
|
const cfg = window.__visionCfg || {};
|
||||||
if (this.busy || this.grabbing || !cfg.grabUrl) return;
|
if (this.busy || this.grabbing || this.registering || !cfg.grabUrl) return false;
|
||||||
|
const gen = this.gen;
|
||||||
this.grabbing = true;
|
this.grabbing = true;
|
||||||
this.error = '';
|
this.error = '';
|
||||||
let blob = null;
|
let blob = null;
|
||||||
try {
|
try {
|
||||||
const csrf = document.querySelector('meta[name=csrf-token]')?.content || '';
|
const csrf = document.querySelector('meta[name=csrf-token]')?.content || '';
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('task_id', cfg.taskId);
|
||||||
const resp = await fetch(cfg.grabUrl, {
|
const resp = await fetch(cfg.grabUrl, {
|
||||||
method: 'POST', headers: { 'X-CSRFToken': csrf },
|
method: 'POST', headers: { 'X-CSRFToken': csrf }, body: fd,
|
||||||
});
|
});
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const body = await resp.json().catch(() => ({}));
|
const body = await resp.json().catch(() => ({}));
|
||||||
this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
if (gen === this.gen) this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
blob = await resp.blob();
|
blob = await resp.blob();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
if (gen === this.gen) this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
||||||
return;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
this.grabbing = false;
|
if (gen === this.gen) this.grabbing = false;
|
||||||
}
|
}
|
||||||
if (blob) await this.acquire(blob);
|
if (!blob || gen !== this.gen) return false;
|
||||||
|
if (this.hasCamera) return await this.analyze(blob);
|
||||||
|
await this.acquire(blob);
|
||||||
|
return !this.error;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Input change: take the file and allow picking the same one again.
|
// Input change: take the file and allow picking the same one again.
|
||||||
|
// #97: with a camera the file is a preview too (the loop pauses), «Registra» saves it.
|
||||||
onPick(ev) {
|
onPick(ev) {
|
||||||
const f = ev.target.files && ev.target.files[0];
|
const f = ev.target.files && ev.target.files[0];
|
||||||
ev.target.value = '';
|
ev.target.value = '';
|
||||||
if (f) this.acquire(f);
|
if (!f) return;
|
||||||
|
if (this.hasCamera) {
|
||||||
|
if (this.inLinea) this.paused = true;
|
||||||
|
this.analyze(f);
|
||||||
|
} else {
|
||||||
|
this.acquire(f);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Image shown: the saved one from the server when available, else the local one.
|
// Image shown: the saved one from the server when available, else the local one.
|
||||||
|
|||||||
@@ -662,7 +662,7 @@
|
|||||||
<!-- ========== Crea ricetta da DXF (#83): solo dxf_compare ========== -->
|
<!-- ========== Crea ricetta da DXF (#83): solo dxf_compare ========== -->
|
||||||
<template x-if="task.task_type === 'dxf_compare'">
|
<template x-if="task.task_type === 'dxf_compare'">
|
||||||
<div class="mt-4 pt-3 border-t border-[var(--border-color)]"
|
<div class="mt-4 pt-3 border-t border-[var(--border-color)]"
|
||||||
x-data="dxfRecipeWizard(task.id, () => csrfToken(), {{ {'requestFailed': _('Richiesta non riuscita'), 'saved': _('Ricetta salvata')}|tojson_attr }})">
|
x-data="dxfRecipeWizard(task.id, () => csrfToken(), {{ {'requestFailed': _('Richiesta non riuscita'), 'saved': _('Ricetta salvata'), 'dxfEntities': _('entità')}|tojson_attr }})">
|
||||||
<button type="button" class="btn btn-secondary text-xs" @click="toggle()">
|
<button type="button" class="btn btn-secondary text-xs" @click="toggle()">
|
||||||
{{ _('Crea ricetta da DXF') }}
|
{{ _('Crea ricetta da DXF') }}
|
||||||
</button>
|
</button>
|
||||||
@@ -680,13 +680,23 @@
|
|||||||
<p x-show="stationId && calibrated" class="mt-1 text-xs text-emerald-600">
|
<p x-show="stationId && calibrated" class="mt-1 text-xs text-emerald-600">
|
||||||
{{ _('Calibrazione') }}: <span x-text="calLabel"></span>
|
{{ _('Calibrazione') }}: <span x-text="calLabel"></span>
|
||||||
</p>
|
</p>
|
||||||
<p x-show="stationId && calState && !calibrated" class="mt-1 text-xs text-amber-600">
|
<p x-show="stationId && calState && !calibrated && manualPxMm === null" class="mt-1 text-xs text-amber-600">{# #98: nascosto con mm/px valido #}
|
||||||
<span x-text="calState === 'uncalibrated' ? {{ _('Stazione non calibrata: calibrala prima di creare la ricetta.')|tojson_attr }} : {{ _('Acquisitore non raggiungibile: impossibile leggere la calibrazione.')|tojson_attr }}"></span>
|
<span x-text="calState === 'uncalibrated' ? {{ _('Stazione non calibrata: calibrala prima di creare la ricetta.')|tojson_attr }} : {{ _('Acquisitore non raggiungibile: impossibile leggere la calibrazione.')|tojson_attr }}"></span>
|
||||||
<a :href="calUrl" target="_blank" rel="noopener" x-show="calUrl"
|
<a :href="calUrl" target="_blank" rel="noopener" x-show="calUrl"
|
||||||
class="font-medium text-primary hover:underline">{{ _('Calibra') }}</a>
|
class="font-medium text-primary hover:underline">{{ _('Calibra') }}</a>
|
||||||
<span class="text-[var(--text-muted)]">({{ _('oppure vedi la pagina Stazioni') }})</span>
|
<span class="text-[var(--text-muted)]">({{ _('oppure vedi la pagina Stazioni') }})</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- #98: mm/px a mano per immagini da file, sostituisce la calibrazione della stazione -->
|
||||||
|
<div>
|
||||||
|
<label class="tmf-label text-xs">{{ _('mm/px (immagine da file)') }}</label>
|
||||||
|
<input type="text" inputmode="decimal" class="tmf-input text-xs" x-model="mmPx" placeholder="0,01406">
|
||||||
|
<p x-show="manualPxMm !== null" class="mt-1 text-xs text-emerald-600">
|
||||||
|
= <span x-text="fmt(manualPxMm)"></span> px/mm
|
||||||
|
</p>
|
||||||
|
<p x-show="mmPxInvalid" class="mt-1 text-xs text-amber-600">{{ _('mm/px non valido') }}</p>
|
||||||
|
<p class="mt-1 text-xs text-[var(--text-muted)]">{{ _('Se compilato sostituisce la calibrazione della stazione; la scala resta salvata nella ricetta.') }}</p>
|
||||||
|
</div>
|
||||||
<!-- Immagine: camera o file -->
|
<!-- Immagine: camera o file -->
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -704,6 +714,20 @@
|
|||||||
<div>
|
<div>
|
||||||
<label class="tmf-label text-xs">{{ _('Disegno (.dxf)') }}</label>
|
<label class="tmf-label text-xs">{{ _('Disegno (.dxf)') }}</label>
|
||||||
<input type="file" accept=".dxf" class="tmf-input text-xs" @change="onDxf($event)">
|
<input type="file" accept=".dxf" class="tmf-input text-xs" @change="onDxf($event)">
|
||||||
|
<!-- #101: anteprima del disegno appena scelto il .dxf -->
|
||||||
|
<p x-show="dxfPreviewBusy" class="mt-1 text-xs text-[var(--text-muted)]">{{ _('Lettura DXF…') }}</p>
|
||||||
|
<p x-show="dxfPreviewError" class="mt-1 text-xs text-red-500" x-text="dxfPreviewError"></p>
|
||||||
|
<div x-show="dxfPreview" class="mt-2">
|
||||||
|
<svg x-bind:view-box.camel="dxfPreviewViewBox()" preserveAspectRatio="xMidYMid meet"
|
||||||
|
class="block w-full rounded border border-[var(--border-color)]"
|
||||||
|
style="max-height: 240px; background: var(--bg-primary);">
|
||||||
|
<!-- #101: un solo path (x-for/template non funziona nel namespace SVG); view-box.camel perche' il parser HTML abbassa viewBox -->
|
||||||
|
<path :d="dxfPreviewD()" fill="none" stroke="var(--color-primary, #2563eb)"
|
||||||
|
stroke-width="1.5" vector-effect="non-scaling-stroke"
|
||||||
|
stroke-linejoin="round"></path>
|
||||||
|
</svg>
|
||||||
|
<p class="mt-1 text-xs text-[var(--text-muted)]" x-text="dxfPreviewSummary"></p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Pochi parametri -->
|
<!-- Pochi parametri -->
|
||||||
|
|||||||
@@ -534,9 +534,10 @@
|
|||||||
<div x-show="isVisionTask" x-cloak
|
<div x-show="isVisionTask" x-cloak
|
||||||
x-data="visionExecute()"
|
x-data="visionExecute()"
|
||||||
x-init="ctx = () => ({ lot: lotNumber, serial: serialNumber, runId: productionRun ? productionRun.id : null })"
|
x-init="ctx = () => ({ lot: lotNumber, serial: serialNumber, runId: productionRun ? productionRun.id : null })"
|
||||||
class="flex-1 overflow-auto p-3 flex flex-col lg:flex-row gap-3">
|
class="flex-1 min-h-0 overflow-hidden p-3 flex flex-col lg:flex-row gap-3">
|
||||||
|
{# #96: il pannello non scorre mai (overflow-hidden, min-h-0): l'immagine sta in fit nello spazio che resta #}
|
||||||
|
|
||||||
<div class="flex-1 min-w-0 flex flex-col gap-2">
|
<div class="flex-1 min-w-0 min-h-0 flex flex-col gap-2">
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
{# On a tablet this opens the camera; on a PC it picks a file. «Riprova» reuses the same input. #}
|
{# On a tablet this opens the camera; on a PC it picks a file. «Riprova» reuses the same input. #}
|
||||||
<label class="btn btn-primary text-sm gap-1.5 cursor-pointer" :class="busy ? 'opacity-50 pointer-events-none' : ''">
|
<label class="btn btn-primary text-sm gap-1.5 cursor-pointer" :class="busy ? 'opacity-50 pointer-events-none' : ''">
|
||||||
@@ -545,12 +546,29 @@
|
|||||||
@change="onPick($event)" :disabled="busy">
|
@change="onPick($event)" :disabled="busy">
|
||||||
</label>
|
</label>
|
||||||
{% if camera_enabled %}
|
{% if camera_enabled %}
|
||||||
{# #72: frame dalla camera della stazione, stessa strada di acquire() #}
|
{# #97: camera del task (o della stazione). Impulsiva: «Acquisisci» rifa grab+check; in linea: «Pausa»/«Riprendi» sul ciclo #}
|
||||||
|
{% if camera_mode == 'in_linea' %}
|
||||||
|
<button type="button" class="btn btn-secondary text-sm gap-1.5" @click="togglePause()"
|
||||||
|
:class="registering ? 'opacity-50 pointer-events-none' : ''"
|
||||||
|
:disabled="registering">
|
||||||
|
<span x-text="(paused || saved) ? '{{ _('Riprendi') }}' : '{{ _('Pausa') }}'"></span>
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
{# #72: frame dalla camera, stessa strada di acquire(); #97: ora grab + analisi senza salvare #}
|
||||||
<button type="button" class="btn btn-secondary text-sm gap-1.5" @click="grab()"
|
<button type="button" class="btn btn-secondary text-sm gap-1.5" @click="grab()"
|
||||||
:class="(busy || grabbing) ? 'opacity-50 pointer-events-none' : ''"
|
:class="(busy || grabbing) ? 'opacity-50 pointer-events-none' : ''"
|
||||||
:disabled="busy || grabbing">
|
:disabled="busy || grabbing">
|
||||||
<span>{{ _('Acquisisci dalla camera') }}</span>
|
<span>{{ _('Acquisisci') }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{# #97: salva l'ULTIMO frame mostrato (execute); fino ad allora e' solo un'anteprima #}
|
||||||
|
<button type="button" class="btn btn-primary text-sm gap-1.5" @click="record()"
|
||||||
|
:class="canRecord ? '' : 'opacity-50 pointer-events-none'"
|
||||||
|
:disabled="!canRecord">
|
||||||
|
<span>{{ _('Registra') }}</span>
|
||||||
|
</button>
|
||||||
|
<span x-show="preview && !saved" x-cloak
|
||||||
|
class="inline-block px-2 py-0.5 rounded text-xs font-bold bg-amber-50 dark:bg-amber-900/20 border border-amber-300 dark:border-amber-700">{{ _('Anteprima, non registrata') }}</span>
|
||||||
<span x-show="grabbing" class="text-sm text-[var(--text-muted)]">{{ _('Acquisizione in corso...') }}</span>
|
<span x-show="grabbing" class="text-sm text-[var(--text-muted)]">{{ _('Acquisizione in corso...') }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span x-show="busy" class="text-sm text-[var(--text-muted)]">{{ _('Elaborazione in corso...') }}</span>
|
<span x-show="busy" class="text-sm text-[var(--text-muted)]">{{ _('Elaborazione in corso...') }}</span>
|
||||||
@@ -558,16 +576,18 @@
|
|||||||
|
|
||||||
<div x-show="error" class="p-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-sm text-measure-fail" x-text="error"></div>
|
<div x-show="error" class="p-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-sm text-measure-fail" x-text="error"></div>
|
||||||
|
|
||||||
<div x-show="shownImage" class="relative border border-[var(--border-color)] rounded-lg overflow-hidden bg-black/5">
|
{# #96: riquadro flex-1 min-h-0; img e canvas object-contain sovrapposti (il canvas ha la stessa risoluzione naturale dell'immagine, quindi l'overlay resta allineato) #}
|
||||||
<img x-ref="img" :src="shownImage" @load="drawOverlay()" alt="" class="block w-full h-auto">
|
<div x-show="shownImage" class="relative flex-1 min-h-0 border border-[var(--border-color)] rounded-lg overflow-hidden bg-black/5">
|
||||||
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full pointer-events-none"></canvas>
|
<img x-ref="img" :src="shownImage" @load="drawOverlay()" alt="" class="absolute inset-0 w-full h-full object-contain">
|
||||||
|
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full object-contain pointer-events-none"></canvas>
|
||||||
</div>
|
</div>
|
||||||
<div x-show="!shownImage" class="text-sm text-[var(--text-muted)]">
|
<div x-show="!shownImage" class="text-sm text-[var(--text-muted)]">
|
||||||
{{ _('Nessuna immagine acquisita: premi «Acquisisci immagine».') }}
|
{{ _('Nessuna immagine acquisita: premi «Acquisisci immagine».') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="lg:w-80 shrink-0 flex flex-col gap-2" x-show="result">
|
{# #96: colonna esito con scorrimento proprio (solo con molte quote); sotto lg max 40% dell'altezza #}
|
||||||
|
<div class="lg:w-80 shrink-0 min-h-0 overflow-y-auto max-h-[40%] lg:max-h-none flex flex-col gap-2" x-show="result">
|
||||||
<div class="text-sm font-bold text-[var(--text-primary)]">
|
<div class="text-sm font-bold text-[var(--text-primary)]">
|
||||||
{{ _('Esito') }}: <span x-text="(result && result.outcome) || '-'"></span>
|
{{ _('Esito') }}: <span x-text="(result && result.outcome) || '-'"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1146,6 +1166,9 @@
|
|||||||
window.__visionCfg = {
|
window.__visionCfg = {
|
||||||
executeUrl: '{{ url_for("measure.api_vision_execute") }}',
|
executeUrl: '{{ url_for("measure.api_vision_execute") }}',
|
||||||
grabUrl: '{{ url_for("measure.api_vision_grab") }}',
|
grabUrl: '{{ url_for("measure.api_vision_grab") }}',
|
||||||
|
checkUrl: '{{ url_for("measure.api_vision_check") }}', {# #97: analisi senza salvare #}
|
||||||
|
cameraAvailable: {{ (camera_enabled|default(false))|tojson }},
|
||||||
|
modo: {{ (camera_mode|default('impulsiva'))|tojson }},
|
||||||
imageUrlTpl: '{{ url_for("measure.api_vision_result_image", result_id=0) }}',
|
imageUrlTpl: '{{ url_for("measure.api_vision_result_image", result_id=0) }}',
|
||||||
taskId: {{ task.id|tojson }},
|
taskId: {{ task.id|tojson }},
|
||||||
taskType: {{ (task.task_type or '')|tojson }},
|
taskType: {{ (task.task_type or '')|tojson }},
|
||||||
|
|||||||
@@ -51,7 +51,9 @@
|
|||||||
<!-- Subtask -->
|
<!-- Subtask -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-medium text-steel mb-1">{{ _("Punto di misura") }}</label>
|
<label class="block text-xs font-medium text-steel mb-1">{{ _("Punto di misura") }}</label>
|
||||||
|
<!-- #100: il cambio di punto ricarica subito, senza «Applica filtri» -->
|
||||||
<select x-model="selectedSubtaskId"
|
<select x-model="selectedSubtaskId"
|
||||||
|
@change="loadData()"
|
||||||
:disabled="subtasks.length === 0"
|
:disabled="subtasks.length === 0"
|
||||||
class="w-full rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] px-3 py-2 text-sm focus:ring-2 focus:ring-primary focus:border-primary disabled:opacity-50">
|
class="w-full rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] px-3 py-2 text-sm focus:ring-2 focus:ring-primary focus:border-primary disabled:opacity-50">
|
||||||
<option value="">{{ _("Tutti i punti") }}</option>
|
<option value="">{{ _("Tutti i punti") }}</option>
|
||||||
@@ -287,20 +289,52 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!capability || capability.cp == null">
|
<!-- #100: con «Tutti i punti» tabella con una riga per punto di misura -->
|
||||||
|
<template x-if="!loadedSubtaskId && capabilityAll.length > 0">
|
||||||
|
<div>
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-xs text-steel text-left">
|
||||||
|
<th class="py-1 font-medium">{{ _("Punto di misura") }}</th>
|
||||||
|
<th class="py-1 font-medium text-right">n</th>
|
||||||
|
<th class="py-1 font-medium text-right">Cp</th>
|
||||||
|
<th class="py-1 font-medium text-right">Cpk</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-for="row in capabilityAll" :key="row.subtask.id">
|
||||||
|
<tr class="cursor-pointer hover:bg-[var(--bg-primary)] border-t border-[var(--border-color)]"
|
||||||
|
@click="selectedSubtaskId = String(row.subtask.id); loadData()">
|
||||||
|
<td class="py-1.5 pr-2" x-text="'#' + row.subtask.marker_number + ' — ' + row.subtask.description"></td>
|
||||||
|
<td class="py-1.5 text-right font-mono" x-text="row.data && row.data.n != null ? row.data.n : '—'"></td>
|
||||||
|
<td class="py-1.5 text-right font-mono font-bold"
|
||||||
|
:class="row.data && row.data.cp != null ? capabilityColor(row.data.cp) : 'text-steel'"
|
||||||
|
x-text="row.data && row.data.cp != null ? row.data.cp : '—'"></td>
|
||||||
|
<td class="py-1.5 text-right font-mono font-bold"
|
||||||
|
:class="row.data && row.data.cpk != null ? capabilityColor(row.data.cpk) : 'text-steel'"
|
||||||
|
x-text="row.data && row.data.cpk != null ? row.data.cpk : '—'"></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="text-xs text-steel mt-3">{{ _("Scegli un punto per carta di controllo e istogramma") }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="(!capability || capability.cp == null) && (loadedSubtaskId || capabilityAll.length === 0)">
|
||||||
<p class="text-center text-steel py-8">{{ _("Seleziona un punto di misura per calcolare gli indici") }}</p>
|
<p class="text-center text-steel py-8">{{ _("Seleziona un punto di misura per calcolare gli indici") }}</p>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Row 2: Carta di Controllo -->
|
<!-- Row 2: Carta di Controllo -->
|
||||||
<div class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 shadow-sm mb-6">
|
<!-- #100: x-show (non x-if) perche' renderCharts() cerca gli id nel DOM; nascosta senza punto -->
|
||||||
|
<div x-show="loadedSubtaskId" class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 shadow-sm mb-6">
|
||||||
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Carta di Controllo") }}</h2>
|
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Carta di Controllo") }}</h2>
|
||||||
<div id="control-chart" style="min-height: 350px;"></div>
|
<div id="control-chart" style="min-height: 350px;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Row 3: Istogramma -->
|
<!-- Row 3: Istogramma -->
|
||||||
<div class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 shadow-sm mb-6">
|
<div x-show="loadedSubtaskId" class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 shadow-sm mb-6">
|
||||||
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Istogramma") }}</h2>
|
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Istogramma") }}</h2>
|
||||||
<div id="histogram-chart" style="min-height: 350px;"></div>
|
<div id="histogram-chart" style="min-height: 350px;"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -346,6 +380,8 @@ function spcDashboard() {
|
|||||||
subtasks: [],
|
subtasks: [],
|
||||||
|
|
||||||
// Data state
|
// Data state
|
||||||
|
loadedSubtaskId: '', // #100 punto con cui sono stati caricati i dati (i pannelli dipendono da questo)
|
||||||
|
capabilityAll: [], // #100 con «Tutti i punti»: [{subtask, data}] una riga per punto
|
||||||
summary: null,
|
summary: null,
|
||||||
capability: null,
|
capability: null,
|
||||||
controlChart: null,
|
controlChart: null,
|
||||||
@@ -408,6 +444,8 @@ function spcDashboard() {
|
|||||||
|
|
||||||
async onRecipeChange() {
|
async onRecipeChange() {
|
||||||
this.selectedSubtaskId = '';
|
this.selectedSubtaskId = '';
|
||||||
|
this.loadedSubtaskId = ''; // #100
|
||||||
|
this.capabilityAll = []; // #100
|
||||||
this.selectedRunId = ''; // #58
|
this.selectedRunId = ''; // #58
|
||||||
this.runs = [];
|
this.runs = [];
|
||||||
this.subtasks = [];
|
this.subtasks = [];
|
||||||
@@ -459,10 +497,11 @@ function spcDashboard() {
|
|||||||
if (this.selectedRunId) params.set('production_run_id', this.selectedRunId); // #58
|
if (this.selectedRunId) params.set('production_run_id', this.selectedRunId); // #58
|
||||||
if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString());
|
if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString());
|
||||||
if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString());
|
if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString());
|
||||||
|
const requestedSubtaskId = this.selectedSubtaskId; // #100 punto richiesto da questa chiamata
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Parallel fetch of all endpoints
|
// Parallel fetch of all endpoints
|
||||||
const [summaryResp, capabilityResp, controlResp, histogramResp] = await Promise.allSettled([
|
const [summaryResp, capabilityResp, controlResp, histogramResp, allResp] = await Promise.allSettled([
|
||||||
fetch('/statistics/api/summary?' + params.toString()).then(r => r.json()),
|
fetch('/statistics/api/summary?' + params.toString()).then(r => r.json()),
|
||||||
this.selectedSubtaskId
|
this.selectedSubtaskId
|
||||||
? fetch('/statistics/api/capability?' + params.toString()).then(r => r.json())
|
? fetch('/statistics/api/capability?' + params.toString()).then(r => r.json())
|
||||||
@@ -473,6 +512,17 @@ function spcDashboard() {
|
|||||||
this.selectedSubtaskId
|
this.selectedSubtaskId
|
||||||
? fetch('/statistics/api/histogram?' + params.toString()).then(r => r.json())
|
? fetch('/statistics/api/histogram?' + params.toString()).then(r => r.json())
|
||||||
: Promise.resolve(null),
|
: Promise.resolve(null),
|
||||||
|
// #100: senza punto scelto, capability di ogni punto della ricetta
|
||||||
|
requestedSubtaskId
|
||||||
|
? Promise.resolve([])
|
||||||
|
: Promise.all(this.subtasks.map(st => {
|
||||||
|
const p = new URLSearchParams(params.toString());
|
||||||
|
p.set('subtask_id', st.id);
|
||||||
|
return fetch('/statistics/api/capability?' + p.toString())
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => ({ subtask: st, data: (d && !d.error) ? d : null }))
|
||||||
|
.catch(() => ({ subtask: st, data: null }));
|
||||||
|
})),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Summary (always available)
|
// Summary (always available)
|
||||||
@@ -505,6 +555,9 @@ function spcDashboard() {
|
|||||||
this.histogram = null;
|
this.histogram = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #100: lista per-punto solo con «Tutti i punti»; con un punto resta il flusso di prima
|
||||||
|
this.capabilityAll = (allResp.status === 'fulfilled' && Array.isArray(allResp.value)) ? allResp.value : [];
|
||||||
|
this.loadedSubtaskId = requestedSubtaskId;
|
||||||
this.hasData = true;
|
this.hasData = true;
|
||||||
|
|
||||||
// Render charts after DOM update
|
// Render charts after DOM update
|
||||||
@@ -522,7 +575,7 @@ function spcDashboard() {
|
|||||||
|
|
||||||
renderCharts() {
|
renderCharts() {
|
||||||
// Control chart
|
// Control chart
|
||||||
if (this.controlChart && this.selectedSubtaskId) {
|
if (this.controlChart && this.loadedSubtaskId) { // #100
|
||||||
renderControlChart('control-chart', this.controlChart);
|
renderControlChart('control-chart', this.controlChart);
|
||||||
} else {
|
} else {
|
||||||
const el = document.getElementById('control-chart');
|
const el = document.getElementById('control-chart');
|
||||||
@@ -530,7 +583,7 @@ function spcDashboard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Histogram
|
// Histogram
|
||||||
if (this.histogram && this.selectedSubtaskId) {
|
if (this.histogram && this.loadedSubtaskId) { // #100
|
||||||
const tol = this.capability ? {
|
const tol = this.capability ? {
|
||||||
utl: this.capability.utl,
|
utl: this.capability.utl,
|
||||||
ltl: this.capability.ltl,
|
ltl: this.capability.ltl,
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* #101: anteprima DXF del wizard «Crea ricetta da DXF», con plain node.
|
||||||
|
* Copre dxfPreviewPaths, dxfPreviewViewBox, dxfPreviewSummary e lo scarto
|
||||||
|
* di una risposta arrivata dopo un cambio di file.
|
||||||
|
*
|
||||||
|
* Run from src/frontend/flask_app: node tests/js/test_dxf_preview.js
|
||||||
|
*/
|
||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
global.document = { documentElement: { lang: 'it' } };
|
||||||
|
const src = fs.readFileSync(
|
||||||
|
path.join(__dirname, '..', '..', 'static', 'js', 'dxf_recipe_wizard.js'), 'utf8',
|
||||||
|
);
|
||||||
|
eval(src + ';global.dxfRecipeWizard = dxfRecipeWizard;');
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
function check(label, cond, extra) {
|
||||||
|
if (cond) console.log('ok - ' + label);
|
||||||
|
else { failures++; console.log('FAIL - ' + label + (extra ? ' :: ' + extra : '')); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const PREVIEW = {
|
||||||
|
contorni: [[[0, -5], [12.3, -5], [12.3, 0], [0, 0], [0, -5]], [[1, -1]]],
|
||||||
|
bbox: { xmin: 0, ymin: -5.1, xmax: 12.3, ymax: 0 },
|
||||||
|
larghezza_mm: 12.3, altezza_mm: 5.1, entita: 2, punti: 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const w = dxfRecipeWizard(1, () => 'tok', { dxfEntities: 'entità' });
|
||||||
|
|
||||||
|
check('senza anteprima: nessun path', w.dxfPreviewPaths().length === 0);
|
||||||
|
check('senza anteprima: summary vuoto', w.dxfPreviewSummary === '');
|
||||||
|
|
||||||
|
w.dxfPreview = PREVIEW;
|
||||||
|
const paths = w.dxfPreviewPaths();
|
||||||
|
check('un contorno con >1 punti diventa una polyline (quello da 1 punto no)', paths.length === 1);
|
||||||
|
check('formato points', paths[0] === '0,-5 12.3,-5 12.3,0 0,0 0,-5', paths[0]);
|
||||||
|
|
||||||
|
// #101: path unico per <path :d>
|
||||||
|
check('dxfPreviewD', w.dxfPreviewD() === 'M 0 -5 L 12.3 -5 L 12.3 0 L 0 0 L 0 -5', w.dxfPreviewD());
|
||||||
|
w.dxfPreview = null;
|
||||||
|
check('dxfPreviewD vuoto senza anteprima', w.dxfPreviewD() === '');
|
||||||
|
w.dxfPreview = PREVIEW;
|
||||||
|
|
||||||
|
const vb = w.dxfPreviewViewBox().split(' ').map(Number);
|
||||||
|
const m = Math.max(0.5, 0.05 * 12.3);
|
||||||
|
check('viewBox con margine 5%', Math.abs(vb[0] + m) < 1e-3 && Math.abs(vb[2] - (12.3 + 2 * m)) < 1e-3, vb.join(' '));
|
||||||
|
|
||||||
|
// Disegno minuscolo: margine minimo 0.5 mm
|
||||||
|
w.dxfPreview = { ...PREVIEW, bbox: { xmin: 0, ymin: 0, xmax: 1, ymax: 1 } };
|
||||||
|
check('margine minimo 0.5 mm', Math.abs(w.dxfPreviewViewBox().split(' ').map(Number)[0] + 0.5) < 1e-3);
|
||||||
|
|
||||||
|
w.dxfPreview = PREVIEW;
|
||||||
|
const s = w.dxfPreviewSummary;
|
||||||
|
check('summary L × H mm · N entità', s.includes('×') && s.includes('mm') && s.endsWith('2 entità')
|
||||||
|
&& /12[,.]30/.test(s) && /5[,.]10/.test(s), s);
|
||||||
|
|
||||||
|
// Risposta tardiva scartata: due scelte di file, la prima risponde per ultima
|
||||||
|
const resolvers = [];
|
||||||
|
global.fetch = () => new Promise(res => resolvers.push(res));
|
||||||
|
global.FormData = class { append() {} };
|
||||||
|
const w2 = dxfRecipeWizard(1, () => 'tok', {});
|
||||||
|
const reply = (body, ok = true) => ({ ok, json: async () => body });
|
||||||
|
w2.dxfFile = { name: 'a.dxf' };
|
||||||
|
const p1 = w2.loadDxfPreview();
|
||||||
|
w2.dxfFile = { name: 'b.dxf' };
|
||||||
|
const p2 = w2.loadDxfPreview();
|
||||||
|
resolvers[1](reply(PREVIEW));
|
||||||
|
await p2;
|
||||||
|
resolvers[0](reply({ ...PREVIEW, entita: 99 }));
|
||||||
|
await p1;
|
||||||
|
check('la risposta vecchia non sovrascrive la nuova', w2.dxfPreview && w2.dxfPreview.entita === 2);
|
||||||
|
check('busy spento', w2.dxfPreviewBusy === false);
|
||||||
|
|
||||||
|
// File tolto: tutto azzerato
|
||||||
|
w2.dxfFile = null;
|
||||||
|
await w2.loadDxfPreview();
|
||||||
|
check('file tolto azzera anteprima ed errore', w2.dxfPreview === null && w2.dxfPreviewError === '');
|
||||||
|
|
||||||
|
// Errore 422 con detail
|
||||||
|
global.fetch = async () => reply({ detail: 'DXF illeggibile: x' }, false);
|
||||||
|
w2.dxfFile = { name: 'c.dxf' };
|
||||||
|
await w2.loadDxfPreview();
|
||||||
|
check('errore col detail', w2.dxfPreviewError === 'DXF illeggibile: x' && w2.dxfPreview === null, w2.dxfPreviewError);
|
||||||
|
|
||||||
|
if (failures) { console.log(failures + ' fallimenti'); process.exit(1); }
|
||||||
|
console.log('tutto ok');
|
||||||
|
})();
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Runs the plain-node DXF preview behaviour test (tests/js/test_dxf_preview.js).
|
||||||
|
|
||||||
|
Same thin pytest wrapper pattern as test_numpad_js.py: shells out to ``node``
|
||||||
|
and asserts the script's own exit code. Skips (does not fail) when node is
|
||||||
|
not installed.
|
||||||
|
|
||||||
|
Covers gitea:Adriano/TieMeasureFlow#101.
|
||||||
|
"""
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
FLASK_APP_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
JS_TEST = Path(__file__).resolve().parent / "js" / "test_dxf_preview.js"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dxf_preview_js_behaviour():
|
||||||
|
node = shutil.which("node")
|
||||||
|
if not node:
|
||||||
|
pytest.skip("node non installato: test JS di dxf_recipe_wizard.js saltato")
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
[node, str(JS_TEST)],
|
||||||
|
cwd=str(FLASK_APP_DIR),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 0, (
|
||||||
|
f"tests/js/test_dxf_preview.js fallito (exit {result.returncode})\n"
|
||||||
|
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
||||||
|
)
|
||||||
@@ -178,6 +178,12 @@ class TestDxfCompareProxy:
|
|||||||
assert set(kwargs["files"]) == {"image", "dxf"}
|
assert set(kwargs["files"]) == {"image", "dxf"}
|
||||||
assert kwargs["files"]["image"][1] == PNG
|
assert kwargs["files"]["image"][1] == PNG
|
||||||
|
|
||||||
|
def test_manual_px_per_mm_forwarded(self, logged_in_client, mock_api_client):
|
||||||
|
# #98: scala a mano dal wizard (immagine da file): inoltrata al backend
|
||||||
|
mock_api_client.post.return_value = self.OK
|
||||||
|
self._post(logged_in_client, station_id="4", px_per_mm="71.1237")
|
||||||
|
assert mock_api_client.post.call_args.kwargs["data"]["px_per_mm"] == "71.1237"
|
||||||
|
|
||||||
def test_save_true_forwarded(self, logged_in_client, mock_api_client):
|
def test_save_true_forwarded(self, logged_in_client, mock_api_client):
|
||||||
mock_api_client.post.return_value = {**self.OK, "saved": True, "recipe_dir": "vision/recipes/7/x"}
|
mock_api_client.post.return_value = {**self.OK, "saved": True, "recipe_dir": "vision/recipes/7/x"}
|
||||||
resp = self._post(logged_in_client, save="true")
|
resp = self._post(logged_in_client, save="true")
|
||||||
@@ -204,6 +210,49 @@ class TestDxfCompareProxy:
|
|||||||
mock_api_client.post.assert_not_called()
|
mock_api_client.post.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestDxfPreviewProxy:
|
||||||
|
"""#101: anteprima del DXF scelto nel wizard (proxy verso il backend)."""
|
||||||
|
URL = "/maker/api/vision/dxf-preview"
|
||||||
|
OK = {
|
||||||
|
"contorni": [[[0.0, -5.0], [10.0, -5.0], [10.0, 0.0], [0.0, 0.0]]],
|
||||||
|
"bbox": {"xmin": 0.0, "ymin": -5.0, "xmax": 10.0, "ymax": 0.0},
|
||||||
|
"larghezza_mm": 10.0, "altezza_mm": 5.0, "entita": 1, "punti": 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _post(self, client, with_file=True):
|
||||||
|
data = {"dxf": (io.BytesIO(b"0\nEOF"), "d.dxf")} if with_file else {}
|
||||||
|
return client.post(self.URL, data=data, content_type="multipart/form-data")
|
||||||
|
|
||||||
|
def test_forwards_file(self, logged_in_client, mock_api_client):
|
||||||
|
mock_api_client.post.return_value = self.OK
|
||||||
|
resp = self._post(logged_in_client)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["larghezza_mm"] == 10.0
|
||||||
|
args, kwargs = mock_api_client.post.call_args
|
||||||
|
assert args[0] == "/api/vision/dxf-preview"
|
||||||
|
assert set(kwargs["files"]) == {"dxf"}
|
||||||
|
assert kwargs["files"]["dxf"][0] == "d.dxf"
|
||||||
|
assert kwargs["files"]["dxf"][1] == b"0\nEOF"
|
||||||
|
|
||||||
|
def test_missing_file_is_400(self, logged_in_client, mock_api_client):
|
||||||
|
resp = self._post(logged_in_client, with_file=False)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
mock_api_client.post.assert_not_called()
|
||||||
|
|
||||||
|
def test_backend_422_propagates(self, logged_in_client, mock_api_client):
|
||||||
|
mock_api_client.post.return_value = {
|
||||||
|
"error": True, "status_code": 422, "detail": "DXF illeggibile: x"}
|
||||||
|
resp = self._post(logged_in_client)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.get_json()["detail"] == "DXF illeggibile: x"
|
||||||
|
|
||||||
|
def test_requires_maker_role(self, logged_in_client, mock_api_client):
|
||||||
|
with logged_in_client.session_transaction() as sess:
|
||||||
|
sess["user"] = {**sess["user"], "roles": ["MeasurementTec"]}
|
||||||
|
assert self._post(logged_in_client).status_code == 403
|
||||||
|
mock_api_client.post.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
class TestPages:
|
class TestPages:
|
||||||
def test_stations_page_has_calibration_cell(self, logged_in_client):
|
def test_stations_page_has_calibration_cell(self, logged_in_client):
|
||||||
with patch("blueprints.admin.api_client") as admin_api:
|
with patch("blueprints.admin.api_client") as admin_api:
|
||||||
@@ -229,4 +278,6 @@ class TestPages:
|
|||||||
assert "js/dxf_recipe_wizard.js" in html
|
assert "js/dxf_recipe_wizard.js" in html
|
||||||
assert "dxfRecipeWizard(" in html
|
assert "dxfRecipeWizard(" in html
|
||||||
assert "@dxf-recipe-saved" in html
|
assert "@dxf-recipe-saved" in html
|
||||||
|
assert 'x-model="mmPx"' in html # #98: campo mm/px a mano
|
||||||
|
assert "dxfPreviewD()" in html and "view-box.camel" in html # #101: anteprima SVG del DXF
|
||||||
assert "Pezzo scuro su fondo chiaro" in html or "Dark part on light" in html
|
assert "Pezzo scuro su fondo chiaro" in html or "Dark part on light" in html
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
"""gitea:Adriano/TieMeasureFlow#97 - operator: image already analysed on entry.
|
||||||
|
|
||||||
|
The backend (POST /api/vision/check, task.acquisition_json) is built elsewhere:
|
||||||
|
api_client and requests are mocked here with the contract's shapes.
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from tests.conftest import api_get_router
|
||||||
|
from tests.test_measure_camera_grab import (
|
||||||
|
PNG, RECIPE, STATION, TASK, _fake, _station,
|
||||||
|
)
|
||||||
|
|
||||||
|
ACQ = {"camera_url": "http://acq-host:9000", "camera_source": "camera:601020", "modo": "in_linea"}
|
||||||
|
|
||||||
|
|
||||||
|
def _task_with(acq):
|
||||||
|
return {**TASK, "acquisition_json": acq}
|
||||||
|
|
||||||
|
|
||||||
|
# --- grab: the camera of the task, else the station's -------------------------
|
||||||
|
def test_grab_with_task_id_uses_task_camera(logged_in_client, mock_api_client, monkeypatch):
|
||||||
|
_station(mock_api_client, monkeypatch, STATION)
|
||||||
|
mock_api_client.get.side_effect = api_get_router({"/api/tasks/12": _task_with(ACQ)})
|
||||||
|
with patch("blueprints.measure.http_requests.post", return_value=_fake()) as post:
|
||||||
|
resp = logged_in_client.post("/measure/api/vision/grab", data={"task_id": "12"})
|
||||||
|
assert resp.status_code == 200 and resp.data == PNG
|
||||||
|
args, kwargs = post.call_args
|
||||||
|
assert args[0] == "http://acq-host:9000/camera/grab"
|
||||||
|
assert kwargs["json"]["source"] == "camera:601020"
|
||||||
|
mock_api_client.get_station_by_code.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_grab_task_id_in_json_works_too(logged_in_client, mock_api_client, monkeypatch):
|
||||||
|
_station(mock_api_client, monkeypatch, STATION)
|
||||||
|
mock_api_client.get.side_effect = api_get_router({"/api/tasks/12": _task_with(ACQ)})
|
||||||
|
with patch("blueprints.measure.http_requests.post", return_value=_fake()) as post:
|
||||||
|
resp = logged_in_client.post("/measure/api/vision/grab", json={"task_id": 12})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert post.call_args[0][0] == "http://acq-host:9000/camera/grab"
|
||||||
|
|
||||||
|
|
||||||
|
def test_grab_task_without_acquisition_uses_station(logged_in_client, mock_api_client, monkeypatch):
|
||||||
|
_station(mock_api_client, monkeypatch, STATION)
|
||||||
|
mock_api_client.get.side_effect = api_get_router({"/api/tasks/12": _task_with(None)})
|
||||||
|
with patch("blueprints.measure.http_requests.post", return_value=_fake()) as post:
|
||||||
|
resp = logged_in_client.post("/measure/api/vision/grab", data={"task_id": "12"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
args, kwargs = post.call_args
|
||||||
|
assert args[0] == "http://host.docker.internal:8765/camera/grab"
|
||||||
|
assert kwargs["json"]["source"] == "camera:folder"
|
||||||
|
|
||||||
|
|
||||||
|
def test_grab_task_camera_wins_even_if_station_has_none(logged_in_client, mock_api_client, monkeypatch):
|
||||||
|
_station(mock_api_client, monkeypatch, {**STATION, "camera_url": None})
|
||||||
|
mock_api_client.get.side_effect = api_get_router({"/api/tasks/12": _task_with(ACQ)})
|
||||||
|
with patch("blueprints.measure.http_requests.post", return_value=_fake()) as post:
|
||||||
|
resp = logged_in_client.post("/measure/api/vision/grab", data={"task_id": "12"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert post.call_args[0][0] == "http://acq-host:9000/camera/grab"
|
||||||
|
|
||||||
|
|
||||||
|
def test_grab_task_lookup_error_falls_back_to_station(logged_in_client, mock_api_client, monkeypatch):
|
||||||
|
_station(mock_api_client, monkeypatch, STATION)
|
||||||
|
mock_api_client.get.side_effect = api_get_router(
|
||||||
|
{"/api/tasks/12": {"error": True, "status_code": 404, "detail": "x"}})
|
||||||
|
with patch("blueprints.measure.http_requests.post", return_value=_fake()) as post:
|
||||||
|
resp = logged_in_client.post("/measure/api/vision/grab", data={"task_id": "12"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert post.call_args[0][0] == "http://host.docker.internal:8765/camera/grab"
|
||||||
|
|
||||||
|
|
||||||
|
def test_grab_acquisition_as_json_string(logged_in_client, mock_api_client, monkeypatch):
|
||||||
|
_station(mock_api_client, monkeypatch, {**STATION, "camera_url": None})
|
||||||
|
mock_api_client.get.side_effect = api_get_router(
|
||||||
|
{"/api/tasks/12": _task_with(json.dumps(ACQ))})
|
||||||
|
with patch("blueprints.measure.http_requests.post", return_value=_fake()) as post:
|
||||||
|
resp = logged_in_client.post("/measure/api/vision/grab", data={"task_id": "12"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert post.call_args[0][0] == "http://acq-host:9000/camera/grab"
|
||||||
|
|
||||||
|
|
||||||
|
# --- check proxy ---------------------------------------------------------------
|
||||||
|
CHECK_OK = {
|
||||||
|
"outcome": "ok", "overlay": [], "failures": [], "calibration": "ok",
|
||||||
|
"measurements": [{"subtask_id": 1, "value": 9.9, "pass_fail": "pass"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_proxy_forwards_multipart(logged_in_client, mock_api_client):
|
||||||
|
mock_api_client.post.return_value = CHECK_OK
|
||||||
|
resp = logged_in_client.post(
|
||||||
|
"/measure/api/vision/check",
|
||||||
|
data={
|
||||||
|
"task_id": "12", "production_run_id": "4",
|
||||||
|
"image": (io.BytesIO(b"\xff\xd8\xff fake"), "shot.jpg", "image/jpeg"),
|
||||||
|
},
|
||||||
|
content_type="multipart/form-data",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["measurements"][0]["pass_fail"] == "pass"
|
||||||
|
args, kwargs = mock_api_client.post.call_args
|
||||||
|
assert args[0] == "/api/vision/check"
|
||||||
|
assert kwargs["data"] == {"task_id": "12", "production_run_id": "4"}
|
||||||
|
name, content, mime = kwargs["files"]["image"]
|
||||||
|
assert name == "shot.jpg" and mime == "image/jpeg" and content.startswith(b"\xff\xd8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_proxy_without_run_leaves_it_out(logged_in_client, mock_api_client):
|
||||||
|
mock_api_client.post.return_value = CHECK_OK
|
||||||
|
logged_in_client.post(
|
||||||
|
"/measure/api/vision/check",
|
||||||
|
data={"task_id": "12", "image": (io.BytesIO(b"x"), "a.jpg", "image/jpeg")},
|
||||||
|
content_type="multipart/form-data",
|
||||||
|
)
|
||||||
|
assert mock_api_client.post.call_args[1]["data"] == {"task_id": "12"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_proxy_requires_image(logged_in_client, mock_api_client):
|
||||||
|
resp = logged_in_client.post("/measure/api/vision/check", data={"task_id": "12"})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
mock_api_client.post.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_proxy_relays_backend_error(logged_in_client, mock_api_client):
|
||||||
|
mock_api_client.post.return_value = {"error": True, "status_code": 422, "detail": "nessun grafo"}
|
||||||
|
resp = logged_in_client.post(
|
||||||
|
"/measure/api/vision/check",
|
||||||
|
data={"task_id": "12", "image": (io.BytesIO(b"x"), "a.jpg", "image/jpeg")},
|
||||||
|
content_type="multipart/form-data",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.get_json()["detail"] == "nessun grafo"
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_requires_login(client):
|
||||||
|
resp = client.post("/measure/api/vision/check")
|
||||||
|
assert resp.status_code in (302, 401)
|
||||||
|
|
||||||
|
|
||||||
|
# --- execute page: buttons and config -------------------------------------------
|
||||||
|
def _page(logged_in_client, mock_api_client, monkeypatch, station, task):
|
||||||
|
_station(mock_api_client, monkeypatch, station)
|
||||||
|
mock_api_client.get_station_recipes.return_value = {"items": [{"id": 3}]}
|
||||||
|
mock_api_client.get.side_effect = api_get_router({
|
||||||
|
"/api/tasks/12": task,
|
||||||
|
"/api/recipes/3/tasks": [{"id": 12, "order_index": 0}],
|
||||||
|
"/api/recipes/3": RECIPE,
|
||||||
|
})
|
||||||
|
return logged_in_client.get("/measure/execute/12").get_data(as_text=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_impulsive_has_acquire_and_record(logged_in_client, mock_api_client, monkeypatch):
|
||||||
|
html = _page(logged_in_client, mock_api_client, monkeypatch, STATION, TASK)
|
||||||
|
assert 'cameraAvailable: true' in html
|
||||||
|
assert 'modo: "impulsiva"' in html
|
||||||
|
assert "/measure/api/vision/check" in html
|
||||||
|
assert "grab()" in html and "record()" in html
|
||||||
|
assert "togglePause()" not in html
|
||||||
|
assert "Anteprima, non registrata" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_in_line_has_pause_and_record(logged_in_client, mock_api_client, monkeypatch):
|
||||||
|
html = _page(logged_in_client, mock_api_client, monkeypatch, STATION, _task_with(ACQ))
|
||||||
|
assert 'modo: "in_linea"' in html
|
||||||
|
assert "togglePause()" in html and "record()" in html
|
||||||
|
assert "grab()" not in html # the loop grabs by itself
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_task_camera_enables_it_without_station_camera(logged_in_client, mock_api_client, monkeypatch):
|
||||||
|
html = _page(logged_in_client, mock_api_client, monkeypatch,
|
||||||
|
{**STATION, "camera_url": None}, _task_with({**ACQ, "modo": "impulsiva"}))
|
||||||
|
assert 'cameraAvailable: true' in html
|
||||||
|
assert "grab()" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_without_camera_is_as_before(logged_in_client, mock_api_client, monkeypatch):
|
||||||
|
html = _page(logged_in_client, mock_api_client, monkeypatch,
|
||||||
|
{**STATION, "camera_url": None}, TASK)
|
||||||
|
assert 'cameraAvailable: false' in html
|
||||||
|
assert "record()" not in html and "togglePause()" not in html and "grab()" not in html
|
||||||
@@ -167,3 +167,11 @@ class TestRunTaskResultsProxy:
|
|||||||
assert 'id="run-task-results"' in html
|
assert 'id="run-task-results"' in html
|
||||||
assert "loadTaskResults" in html
|
assert "loadTaskResults" in html
|
||||||
assert "/statistics/api/runs/" in html
|
assert "/statistics/api/runs/" in html
|
||||||
|
|
||||||
|
def test_dashboard_all_points_overview(self, logged_in_client, mock_api_client):
|
||||||
|
# #100: con «Tutti i punti» la pagina ha tabella per-punto e select che ricarica da solo
|
||||||
|
mock_api_client.get.return_value = {"items": [], "total": 0, "pages": 1}
|
||||||
|
html = logged_in_client.get("/statistics/dashboard").get_data(as_text=True)
|
||||||
|
assert "capabilityAll" in html
|
||||||
|
assert "loadedSubtaskId" in html
|
||||||
|
assert '@change="loadData()"' in html
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""gitea:Adriano/TieMeasureFlow#96 - l'immagine del task visione sta in fit, senza scorrimento.
|
||||||
|
|
||||||
|
Controllo sulle classi del template: pannello overflow-hidden, img/canvas
|
||||||
|
object-contain sovrapposti, nessun h-auto sull'immagine (era la causa dello scorrimento).
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
TEMPLATE = Path(__file__).resolve().parents[1] / "templates" / "measure" / "task_execute.html"
|
||||||
|
|
||||||
|
|
||||||
|
def _panel():
|
||||||
|
src = TEMPLATE.read_text(encoding="utf-8")
|
||||||
|
start = src.rindex("<div", 0, src.index('x-data="visionExecute()"'))
|
||||||
|
end = src.index("MEASUREMENT TIMER BANNER")
|
||||||
|
return src[start:end]
|
||||||
|
|
||||||
|
|
||||||
|
def _tag_with(panel, marker):
|
||||||
|
# Il tag (aperto) che contiene il marker; i '>' dentro le virgolette (es. "=>") non lo chiudono.
|
||||||
|
pos = panel.index(marker)
|
||||||
|
i = panel.rindex("<", 0, pos)
|
||||||
|
quote = None
|
||||||
|
for j in range(i, len(panel)):
|
||||||
|
c = panel[j]
|
||||||
|
if quote:
|
||||||
|
quote = None if c == quote else quote
|
||||||
|
elif c in "\"'":
|
||||||
|
quote = c
|
||||||
|
elif c == ">":
|
||||||
|
return panel[i:j + 1]
|
||||||
|
raise AssertionError(marker)
|
||||||
|
|
||||||
|
|
||||||
|
def test_panel_does_not_scroll():
|
||||||
|
tag = _tag_with(_panel(), 'x-data="visionExecute()"')
|
||||||
|
assert "overflow-hidden" in tag and "min-h-0" in tag
|
||||||
|
assert "overflow-auto" not in tag
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_is_object_contain_without_h_auto():
|
||||||
|
panel = _panel()
|
||||||
|
img = _tag_with(panel, 'x-ref="img"')
|
||||||
|
assert "object-contain" in img
|
||||||
|
assert "h-auto" not in img
|
||||||
|
canvas = _tag_with(panel, 'x-ref="overlay"')
|
||||||
|
assert "object-contain" in canvas # stesso fit dell'img: overlay allineato
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_box_and_outcome_column_can_shrink():
|
||||||
|
panel = _panel()
|
||||||
|
box = _tag_with(panel, 'x-show="shownImage"')
|
||||||
|
assert "flex-1" in box and "min-h-0" in box
|
||||||
|
outcome = _tag_with(panel, 'x-show="result">')
|
||||||
|
assert "overflow-y-auto" in outcome and "min-h-0" in outcome
|
||||||
@@ -3485,3 +3485,17 @@ msgstr "Invalid camera address"
|
|||||||
#: templates/admin/stations.html blueprints/admin.py (#88)
|
#: templates/admin/stations.html blueprints/admin.py (#88)
|
||||||
msgid "Risposta non valida dal servizio camera"
|
msgid "Risposta non valida dal servizio camera"
|
||||||
msgstr "Invalid response from the camera service"
|
msgstr "Invalid response from the camera service"
|
||||||
|
|
||||||
|
#: templates/statistics/dashboard.html (#100)
|
||||||
|
msgid "Scegli un punto per carta di controllo e istogramma"
|
||||||
|
msgstr "Choose a point for the control chart and histogram"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html blueprints/maker.py (#101)
|
||||||
|
msgid "Serve il file DXF"
|
||||||
|
msgstr "The DXF file is required"
|
||||||
|
|
||||||
|
msgid "Lettura DXF…"
|
||||||
|
msgstr "Reading DXF…"
|
||||||
|
|
||||||
|
msgid "entità"
|
||||||
|
msgstr "entities"
|
||||||
|
|||||||
@@ -3473,3 +3473,13 @@ msgstr "Indirizzo della camera non valido"
|
|||||||
#: templates/admin/stations.html blueprints/admin.py (#88)
|
#: templates/admin/stations.html blueprints/admin.py (#88)
|
||||||
msgid "Risposta non valida dal servizio camera"
|
msgid "Risposta non valida dal servizio camera"
|
||||||
msgstr "Risposta non valida dal servizio camera"
|
msgstr "Risposta non valida dal servizio camera"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html blueprints/maker.py (#101)
|
||||||
|
msgid "Serve il file DXF"
|
||||||
|
msgstr "Serve il file DXF"
|
||||||
|
|
||||||
|
msgid "Lettura DXF…"
|
||||||
|
msgstr "Lettura DXF…"
|
||||||
|
|
||||||
|
msgid "entità"
|
||||||
|
msgstr "entità"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import io
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import tempfile # #101
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -288,3 +289,89 @@ async def recipes_dxf_compare(
|
|||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail=f"recipe build failed: {type(exc).__name__}: {exc}",
|
detail=f"recipe build failed: {type(exc).__name__}: {exc}",
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
# #101: tetto ai punti dell'anteprima DXF (un profilo fitto non deve gonfiare
|
||||||
|
# la risposta ne' rallentare il disegno SVG nel browser).
|
||||||
|
_PREVIEW_MAX_POINTS = 20000
|
||||||
|
|
||||||
|
|
||||||
|
def _decimate(points: list[list[float]], keep: int) -> list[list[float]]:
|
||||||
|
"""Decimazione uniforme a `keep` punti (>= 2), primo e ultimo sempre inclusi."""
|
||||||
|
n = len(points)
|
||||||
|
if n <= keep or keep < 2:
|
||||||
|
return points
|
||||||
|
idx = sorted({round(i * (n - 1) / (keep - 1)) for i in range(keep)})
|
||||||
|
return [points[i] for i in idx]
|
||||||
|
|
||||||
|
|
||||||
|
def _dxf_preview(dxf_bytes: bytes) -> dict:
|
||||||
|
"""#101: legge il DXF con lo stesso lettore di CompareDxf (read_xld_dxf) e ne
|
||||||
|
ritorna i contorni in mm, asse y verso il basso (come immagine e SVG).
|
||||||
|
|
||||||
|
Funzione sincrona e pura: la rotta la esegue in un thread. Solleva
|
||||||
|
CostruttoreError per DXF vuoto, illeggibile o senza geometria.
|
||||||
|
"""
|
||||||
|
if not dxf_bytes:
|
||||||
|
raise CostruttoreError("DXF illeggibile: file vuoto")
|
||||||
|
from visionsuite.io.dxf import read_xld_dxf
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = Path(tmp) / "disegno.dxf"
|
||||||
|
path.write_bytes(dxf_bytes)
|
||||||
|
try:
|
||||||
|
# flip_y=False + scale=1: coordinate native del DXF, in mm; il
|
||||||
|
# ribaltamento dell'asse y lo facciamo noi sotto (y = -y_dxf)
|
||||||
|
conts = list(read_xld_dxf(str(path), sampling_distance=0.05,
|
||||||
|
scale=1.0, flip_y=False))
|
||||||
|
except Exception as exc: # noqa: BLE001 - qualunque errore di lettura = DXF illeggibile
|
||||||
|
raise CostruttoreError(f"DXF illeggibile: {type(exc).__name__}: {exc}") from exc
|
||||||
|
|
||||||
|
contorni: list[list[list[float]]] = []
|
||||||
|
for cont in conts:
|
||||||
|
pts = np.asarray(cont.points, dtype=float) # (riga, colonna) = (y_dxf, x_dxf)
|
||||||
|
if pts.ndim != 2 or pts.shape[0] < 2:
|
||||||
|
continue
|
||||||
|
line = [[float(c), float(-r)] for r, c in pts]
|
||||||
|
if cont.closed and line[0] != line[-1]:
|
||||||
|
line.append(list(line[0])) # chiude il giro anche nella polilinea SVG
|
||||||
|
contorni.append(line)
|
||||||
|
if not contorni:
|
||||||
|
raise CostruttoreError("DXF senza geometria")
|
||||||
|
|
||||||
|
# #101: le dimensioni si misurano sui punti completi, prima della decimazione
|
||||||
|
xs = [p[0] for c in contorni for p in c]
|
||||||
|
ys = [p[1] for c in contorni for p in c]
|
||||||
|
xmin, xmax, ymin, ymax = min(xs), max(xs), min(ys), max(ys)
|
||||||
|
|
||||||
|
total = len(xs)
|
||||||
|
if total > _PREVIEW_MAX_POINTS:
|
||||||
|
# quota per contorno proporzionale, minimo 2 punti
|
||||||
|
contorni = [
|
||||||
|
_decimate(c, max(2, int(len(c) * _PREVIEW_MAX_POINTS / total))) for c in contorni
|
||||||
|
]
|
||||||
|
contorni = [[[round(x, 3), round(y, 3)] for x, y in c] for c in contorni]
|
||||||
|
xmin, xmax, ymin, ymax = (round(v, 3) for v in (xmin, xmax, ymin, ymax))
|
||||||
|
return {
|
||||||
|
"contorni": contorni,
|
||||||
|
"bbox": {"xmin": xmin, "ymin": ymin, "xmax": xmax, "ymax": ymax},
|
||||||
|
"larghezza_mm": round(xmax - xmin, 3),
|
||||||
|
"altezza_mm": round(ymax - ymin, 3),
|
||||||
|
"entita": len(contorni),
|
||||||
|
"punti": sum(len(c) for c in contorni),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/dxf/preview")
|
||||||
|
async def dxf_preview(dxf: UploadFile = File(...)) -> dict:
|
||||||
|
"""#101: anteprima del disegno DXF (contorni + dimensioni in mm) per il
|
||||||
|
wizard «Crea ricetta da DXF». Solo lettura, leggera: nessun run slot."""
|
||||||
|
dxf_bytes = await dxf.read()
|
||||||
|
try:
|
||||||
|
return await asyncio.to_thread(_dxf_preview, dxf_bytes)
|
||||||
|
except CostruttoreError as exc:
|
||||||
|
raise _unprocessable(str(exc)) from exc
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"dxf preview failed: {type(exc).__name__}: {exc}",
|
||||||
|
) from exc
|
||||||
|
|||||||
@@ -80,3 +80,67 @@ async def test_dxf_compare_immagine_non_decodificabile_e_422():
|
|||||||
async def test_dxf_compare_parametro_fuori_range_e_422():
|
async def test_dxf_compare_parametro_fuori_range_e_422():
|
||||||
response = await _post(_png(a_scene(0.0)), SAMPLE_DXF.read_bytes(), polarity="grigio")
|
response = await _post(_png(a_scene(0.0)), SAMPLE_DXF.read_bytes(), polarity="grigio")
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
# --- #101: POST /dxf/preview ---------------------------------------------------
|
||||||
|
|
||||||
|
def _rect_dxf(width: float, height: float, y0: float = 3.0) -> bytes:
|
||||||
|
"""DXF minimo con ezdxf: un rettangolo chiuso, y in alto rispetto a 0."""
|
||||||
|
import ezdxf
|
||||||
|
doc = ezdxf.new()
|
||||||
|
doc.modelspace().add_lwpolyline(
|
||||||
|
[(1, y0), (1 + width, y0), (1 + width, y0 + height), (1, y0 + height)], close=True,
|
||||||
|
)
|
||||||
|
buffer = io.StringIO()
|
||||||
|
doc.write(buffer)
|
||||||
|
return buffer.getvalue().encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _post_preview(dxf: bytes):
|
||||||
|
# TestClient sincrono: non serve pytest-asyncio (assente nel venv del worker)
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
return TestClient(app).post(
|
||||||
|
"/dxf/preview", files={"dxf": ("d.dxf", dxf, "application/octet-stream")},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dxf_preview_rettangolo_10x5():
|
||||||
|
resp = _post_preview(_rect_dxf(10, 5))
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["larghezza_mm"] == 10 and body["altezza_mm"] == 5
|
||||||
|
assert body["entita"] == 1 and body["punti"] == sum(len(c) for c in body["contorni"])
|
||||||
|
# y ribaltata: il rettangolo stava a y 3..8 nel DXF, ora a -8..-3 (asse verso il basso)
|
||||||
|
assert body["bbox"] == {"xmin": 1, "ymin": -8, "xmax": 11, "ymax": -3}
|
||||||
|
ring = body["contorni"][0]
|
||||||
|
assert ring[0] == ring[-1] # contorno chiuso: primo == ultimo
|
||||||
|
assert all(-8 <= y <= -3 for _, y in ring)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("content", [b"", b"questo non e' un dxf"])
|
||||||
|
def test_dxf_preview_illeggibile_e_422(content):
|
||||||
|
resp = _post_preview(content)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"].startswith("DXF")
|
||||||
|
|
||||||
|
|
||||||
|
def test_dxf_preview_senza_geometria_e_422():
|
||||||
|
import ezdxf
|
||||||
|
doc = ezdxf.new()
|
||||||
|
buffer = io.StringIO()
|
||||||
|
doc.write(buffer)
|
||||||
|
resp = _post_preview(buffer.getvalue().encode("utf-8"))
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert "senza geometria" in resp.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_dxf_preview_decima_oltre_il_tetto(monkeypatch):
|
||||||
|
from src.vision_worker import main
|
||||||
|
monkeypatch.setattr(main, "_PREVIEW_MAX_POINTS", 50)
|
||||||
|
import ezdxf
|
||||||
|
doc = ezdxf.new()
|
||||||
|
doc.modelspace().add_circle((0, 0), 20)
|
||||||
|
buffer = io.StringIO()
|
||||||
|
doc.write(buffer)
|
||||||
|
full = main._dxf_preview(buffer.getvalue().encode("utf-8"))
|
||||||
|
assert full["punti"] <= 50 + 1 and full["larghezza_mm"] == pytest.approx(40, abs=0.01) # dimensioni dai punti completi
|
||||||
|
|||||||
Reference in New Issue
Block a user