Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9eb5942459 | |||
| 11c7d2e98b | |||
| 88bba213e2 | |||
| 8ce7b489d6 | |||
| bc90d327f1 | |||
| e46965cb51 | |||
| c554fcfda7 | |||
| 990b5d0db8 |
+171
-10
@@ -26,6 +26,23 @@ Calibrazione a scacchiera per sorgente (#83, logica in scripts/calibrazione.py):
|
||||
422 scacchiera non trovata o parametri invalidi
|
||||
DELETE /camera/calibration?source=<src> -> 204 (toglie JSON e mappa)
|
||||
GET /calibrazione -> pagina HTML autonoma (offline) per fare tutto a mano
|
||||
GET /acquisizione -> stessa pagina (#87)
|
||||
Acquisizione (#87): anteprima, esposizione/guadagno, settaggi di default:
|
||||
POST /camera/anteprima json {source, grezzo=false, lato_max=1280 (320..4096),
|
||||
timeout_ms=5000} -> JPEG ridotto (lato lungo <= lato_max, mai ingrandito).
|
||||
Header: X-Raddrizzato si|no, X-Larghezza/X-Altezza (frame PIENO),
|
||||
X-Nitidezza (varianza del Laplaciano), X-Saturati-Pct (% pixel >= 250),
|
||||
X-Media (livello medio 0-255), tutti sul frame pieno in grigio.
|
||||
GET /camera/parametri?source=<src> -> {source, regolabile, parametri:
|
||||
{esposizione, esposizione_auto, guadagno, guadagno_auto: dominio}};
|
||||
dominio = GenicamSource.dominio (valore, tipo, min/max/passo o scelte).
|
||||
Esposizione in us, guadagno in dB. Sorgente senza capacita': regolabile=false.
|
||||
PUT /camera/parametri json {source, valori: {nome: valore}} -> come GET.
|
||||
Scrive prima gli *_auto, poi esposizione/guadagno (assesta=3).
|
||||
400 nome ignoto o corpo errato, 409 non regolabile, 422 camera rifiuta
|
||||
POST /camera/parametri/salva json {source} -> {source, salvati, file}
|
||||
scrive <config-dir>/settaggi/<nome>-<sha1>.json; alla prima apertura
|
||||
della camera nel servizio il file, se c'e', viene ricaricato.
|
||||
Il risultato salvato sta in un JSON per sorgente in --config-dir (default
|
||||
%ProgramData%/Tielogic/TieMeasureFlow/acquisitore su Windows, ./acquisitore altrove).
|
||||
|
||||
@@ -49,6 +66,7 @@ camera a chiunque raggiunga la porta: solo su rete isolata.
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
@@ -61,7 +79,7 @@ from fastapi import Body, FastAPI, File, Form, HTTPException, UploadFile
|
||||
from fastapi.concurrency import run_in_threadpool # #83: il grab bloccante fuori dall'event loop
|
||||
from fastapi.responses import HTMLResponse, Response
|
||||
|
||||
from visionsuite.camera.camera_interface import CameraError, CameraGrabError
|
||||
from visionsuite.camera.camera_interface import CameraError, CameraGrabError, CameraStateError
|
||||
from visionsuite.camera.sources import SourceNotFound, list_sources, open_source
|
||||
|
||||
# #83: calibrazione e pagina stanno accanto a questo file (che non e' un pacchetto)
|
||||
@@ -70,6 +88,11 @@ import calibrazione # noqa: E402
|
||||
from calibrazione_pagina import PAGINA # noqa: E402
|
||||
|
||||
_TRIGGER = ("continuo",)
|
||||
_log = logging.getLogger("camera_service") # #87: errori non bloccanti (settaggi)
|
||||
# #87: i quattro parametri regolabili, nell'ordine di presentazione
|
||||
_PARAMETRI = ("esposizione", "esposizione_auto", "guadagno", "guadagno_auto")
|
||||
# #87: in scrittura prima gli automatismi (un valore manuale con auto acceso viene riscritto)
|
||||
_ORDINE_SCRITTURA = ("esposizione_auto", "guadagno_auto", "esposizione", "guadagno")
|
||||
|
||||
# errori noti di vs-camera (inglese) -> italiano; esatti, poi per prefisso
|
||||
_MESSAGGI = {
|
||||
@@ -140,17 +163,35 @@ def create_app(folder: Path | str | None = None,
|
||||
return {"sources": [{"name": v.name, "kind": v.kind, "label": v.label}
|
||||
for v in voci if v.kind in ("camera", "virtual")]}
|
||||
|
||||
def _grab(nome: str, timeout: int):
|
||||
"""Un frame dalla sorgente (apre e tiene aperta); errori camera -> HTTP."""
|
||||
def _apri(nome: str):
|
||||
"""#87: sorgente aperta (o da aprire); da chiamare SOLO sotto _lock_di(nome)."""
|
||||
with lock:
|
||||
sorgente = aperte.get(nome)
|
||||
if sorgente is None:
|
||||
sorgente = open_source(nome, None, config=_config())
|
||||
_carica_settaggi(nome, sorgente) # #87: predefiniti salvati, se ci sono
|
||||
with lock:
|
||||
aperte[nome] = sorgente
|
||||
return sorgente
|
||||
|
||||
def _file_settaggi(nome: str) -> Path:
|
||||
# #87: stesso schema di nome della calibrazione, in una sottocartella
|
||||
return archivio.cartella / "settaggi" / archivio._file(nome).name
|
||||
|
||||
def _carica_settaggi(nome: str, sorgente) -> None:
|
||||
percorso = _file_settaggi(nome)
|
||||
if not percorso.is_file() or not hasattr(sorgente, "carica_settaggi"):
|
||||
return
|
||||
try:
|
||||
sorgente.carica_settaggi(percorso)
|
||||
except Exception as e: # noqa: BLE001 - un file rotto non blocca l'apertura
|
||||
_log.warning("settaggi di %s non caricati (%s): %s", nome, percorso, e)
|
||||
|
||||
def _con_sorgente(nome: str, fn):
|
||||
"""#87: fn(sorgente) sotto il lock della sorgente; errori camera -> HTTP."""
|
||||
try:
|
||||
with _lock_di(nome): # solo questa sorgente; le altre non aspettano
|
||||
with lock:
|
||||
sorgente = aperte.get(nome)
|
||||
if sorgente is None:
|
||||
sorgente = open_source(nome, None, config=_config())
|
||||
with lock:
|
||||
aperte[nome] = sorgente
|
||||
return sorgente.grab(timeout)
|
||||
return fn(_apri(nome))
|
||||
except SourceNotFound as e:
|
||||
raise HTTPException(404, _traduci(str(e)))
|
||||
except TimeoutError as e:
|
||||
@@ -158,6 +199,10 @@ def create_app(folder: Path | str | None = None,
|
||||
except (CameraGrabError, CameraError) as e:
|
||||
raise HTTPException(503, _traduci(str(e)))
|
||||
|
||||
def _grab(nome: str, timeout: int):
|
||||
"""Un frame dalla sorgente (apre e tiene aperta); errori camera -> HTTP."""
|
||||
return _con_sorgente(nome, lambda s: s.grab(timeout)) # #87: corpo in _con_sorgente
|
||||
|
||||
@app.post("/camera/grab")
|
||||
def camera_grab(corpo: dict = Body(default=None)):
|
||||
nome = corpo.get("source") if isinstance(corpo, dict) else None
|
||||
@@ -241,7 +286,123 @@ def create_app(folder: Path | str | None = None,
|
||||
archivio.salva(nome, corpo, mappa) # #84: la mappa (se densa) va in .npz accanto
|
||||
return {**corpo, "overlay_png": risultato["overlay_png"], "saved": bool(save)}
|
||||
|
||||
# ---- #87: acquisizione (anteprima, parametri, settaggi)
|
||||
|
||||
def _regolabile(s) -> bool:
|
||||
# #87: ha il vocabolario delle capacita' e ne supporta almeno una delle quattro
|
||||
if not (hasattr(s, "supporta") and hasattr(s, "dominio")):
|
||||
return False
|
||||
return any(s.supporta(n) for n in _PARAMETRI)
|
||||
|
||||
def _parametri(nome: str, s) -> dict:
|
||||
"""#87: forma di GET /camera/parametri; da chiamare sotto lock."""
|
||||
parametri: dict = {}
|
||||
if _regolabile(s):
|
||||
for n in _PARAMETRI:
|
||||
if not s.supporta(n):
|
||||
continue
|
||||
try:
|
||||
parametri[n] = s.dominio(n)
|
||||
except CameraStateError as e: # capacita' dichiarata ma non leggibile: si salta
|
||||
_log.warning("dominio di %s su %s non letto: %s", n, nome, e)
|
||||
return {"source": nome, "regolabile": bool(parametri), "parametri": parametri}
|
||||
|
||||
def _grigio8(frame):
|
||||
g = frame if frame.ndim == 2 else cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
||||
if g.dtype == np.uint16: # #87: 16 bit -> 8 bit, per soglia e media coerenti
|
||||
g = (g >> 8).astype(np.uint8)
|
||||
elif g.dtype != np.uint8:
|
||||
g = cv2.convertScaleAbs(g, alpha=255.0 / max(float(g.max()), 1.0))
|
||||
return g
|
||||
|
||||
@app.post("/camera/anteprima")
|
||||
def camera_anteprima(corpo: dict = Body(default=None)):
|
||||
corpo = corpo if isinstance(corpo, dict) else {}
|
||||
nome = _sorgente(corpo.get("source"))
|
||||
grezzo = corpo.get("grezzo", False)
|
||||
if not isinstance(grezzo, bool):
|
||||
raise HTTPException(400, "«grezzo» deve essere true o false")
|
||||
lato = corpo.get("lato_max", 1280)
|
||||
if isinstance(lato, bool) or not isinstance(lato, int) or not 320 <= lato <= 4096:
|
||||
raise HTTPException(400, "«lato_max» deve essere un intero fra 320 e 4096")
|
||||
timeout = corpo.get("timeout_ms", 5000)
|
||||
if isinstance(timeout, bool) or not isinstance(timeout, int) or not 1 <= timeout <= 10000:
|
||||
raise HTTPException(400, "«timeout_ms» deve essere un intero fra 1 e 10000")
|
||||
frame = _grab(nome, timeout)
|
||||
raddrizzato = False
|
||||
if not grezzo: # come /camera/grab
|
||||
mappa = archivio.leggi_mappa(nome)
|
||||
if mappa is not None:
|
||||
corretto = calibrazione.raddrizza(frame, mappa)
|
||||
if corretto is not None:
|
||||
frame, raddrizzato = corretto, True
|
||||
alt, larg = frame.shape[:2]
|
||||
grigio = _grigio8(frame) # misure sul frame PIENO, non sul ridotto
|
||||
nitidezza = float(cv2.Laplacian(grigio, cv2.CV_64F).var())
|
||||
saturati = float((grigio >= 250).mean() * 100.0)
|
||||
media = float(grigio.mean())
|
||||
if frame.dtype != np.uint8: # il JPEG vuole 8 bit
|
||||
frame = grigio
|
||||
scala = lato / max(alt, larg)
|
||||
if scala < 1.0: # mai ingrandita
|
||||
frame = cv2.resize(frame, (max(1, round(larg * scala)), max(1, round(alt * scala))),
|
||||
interpolation=cv2.INTER_AREA)
|
||||
ok, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
||||
if not ok:
|
||||
raise HTTPException(500, "codifica JPEG fallita")
|
||||
return Response(buf.tobytes(), media_type="image/jpeg", headers={
|
||||
"X-Raddrizzato": "si" if raddrizzato else "no",
|
||||
"X-Larghezza": str(larg), "X-Altezza": str(alt),
|
||||
"X-Nitidezza": f"{nitidezza:.1f}", "X-Saturati-Pct": f"{saturati:.2f}",
|
||||
"X-Media": f"{media:.1f}"})
|
||||
|
||||
@app.get("/camera/parametri")
|
||||
def parametri_leggi(source: str = ""):
|
||||
nome = _sorgente(source)
|
||||
return _con_sorgente(nome, lambda s: _parametri(nome, s))
|
||||
|
||||
@app.put("/camera/parametri")
|
||||
def parametri_scrivi(corpo: dict = Body(default=None)):
|
||||
corpo = corpo if isinstance(corpo, dict) else {}
|
||||
nome = _sorgente(corpo.get("source"))
|
||||
valori = corpo.get("valori")
|
||||
if not isinstance(valori, dict) or not valori:
|
||||
raise HTTPException(400, "manca «valori»")
|
||||
for n, v in valori.items():
|
||||
if n not in _PARAMETRI:
|
||||
raise HTTPException(400, f"parametro non riconosciuto: «{n}»")
|
||||
if isinstance(v, bool) or not isinstance(v, (int, float, str)):
|
||||
raise HTTPException(400, f"valore non valido per «{n}»")
|
||||
|
||||
def scrivi(s):
|
||||
if not _regolabile(s):
|
||||
raise HTTPException(409, "questa sorgente non ha parametri regolabili")
|
||||
for n in _ORDINE_SCRITTURA: # prima gli *_auto
|
||||
if n in valori:
|
||||
try:
|
||||
s.scrivi(n, valori[n], assesta=3)
|
||||
except CameraStateError as e:
|
||||
raise HTTPException(422, _traduci(str(e)))
|
||||
return _parametri(nome, s) # valori riletti dalla camera
|
||||
|
||||
return _con_sorgente(nome, scrivi)
|
||||
|
||||
@app.post("/camera/parametri/salva")
|
||||
def parametri_salva(corpo: dict = Body(default=None)):
|
||||
nome = _sorgente(corpo.get("source") if isinstance(corpo, dict) else None)
|
||||
|
||||
def salva(s):
|
||||
if not _regolabile(s) or not hasattr(s, "salva_settaggi"):
|
||||
raise HTTPException(409, "questa sorgente non ha parametri regolabili")
|
||||
percorso = _file_settaggi(nome)
|
||||
percorso.parent.mkdir(parents=True, exist_ok=True)
|
||||
s.salva_settaggi(percorso)
|
||||
return {"source": nome, "salvati": True, "file": percorso.name}
|
||||
|
||||
return _con_sorgente(nome, salva)
|
||||
|
||||
@app.get("/calibrazione", response_class=HTMLResponse)
|
||||
@app.get("/acquisizione", response_class=HTMLResponse) # #87: stessa pagina
|
||||
def calibrazione_pagina():
|
||||
return HTMLResponse(PAGINA)
|
||||
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
"""Seed per il PC della fiera: utenti, stazione, tre ricette e quattro settimane di misure.
|
||||
|
||||
Cosa fa
|
||||
Popola un database TieMeasureFlow (SQLite del pacchetto Windows) cosi' che al primo
|
||||
avvio NON compaia la procedura di setup e la demo abbia gia' qualcosa da mostrare:
|
||||
|
||||
- utenti adriano (admin), maker, capoturno, operatore (password nella costante
|
||||
UTENTI qui sotto, decise da Adriano per la fiera);
|
||||
- la stazione ST-DEFAULT (quella del pacchetto, vedi packaging/windows/avvio.py)
|
||||
con camera_url http://127.0.0.1:8765 e camera_source camera:601020 (Itala);
|
||||
- FIERA-FLANGIA «Flangia Ø80» (5 quote) e FIERA-ALBERO «Albero rettificato»
|
||||
(4 quote), con circa 4 settimane di storico fino a ieri (lun-ven 8-17, una
|
||||
produzione chiusa per giorno, lotti e seriali coerenti, misure con Cpk diversi
|
||||
per quota, una deriva lenta nell'ultima settimana, una quota con 1-3% fuori
|
||||
tolleranza);
|
||||
- FIERA-DXF «Profilo DXF (visione)»: un task dxf_compare senza ricetta di visione
|
||||
(si crea in fiera dal pannello «Crea ricetta da DXF»).
|
||||
|
||||
«Setup fatto» per il backend vuol dire: esiste almeno un utente admin
|
||||
(api/routers/setup.py::_admin_exists), quindi basta creare adriano.
|
||||
|
||||
Come si usa (da daitarn, TMF fermo sul PC, su una COPIA del file SQLite)
|
||||
uv run python scripts/seed_fiera.py --db C:/percorso/tmf.db [--replace] [--dry-run]
|
||||
[--fine AAAA-MM-GG]
|
||||
|
||||
Il file deve avere gia' lo schema (alembic upgrade head: lo fa l'exe al primo
|
||||
avvio). --replace toglie solo i dati FIERA-* (ricette, misure, produzioni) e li
|
||||
rifa'; senza, un secondo giro non duplica le ricette. Gli utenti e la stazione
|
||||
sono idempotenti: se esistono si aggiornano password, ruoli e camera.
|
||||
--dry-run fa tutto e annulla (stampa i numeri). Il seme casuale e' fisso: stesso
|
||||
comando e stessa data di fine, stessi dati.
|
||||
|
||||
Da cosa dipende
|
||||
src.backend (modelli ORM, auth_service, measurement_service), SQLAlchemy +
|
||||
aiosqlite, tzdata su Windows (gia' in pyproject). Non usa MySQL.
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
import random
|
||||
import sys
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import delete, event, select
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession, async_sessionmaker, create_async_engine,
|
||||
)
|
||||
|
||||
# La radice del repo nel path, cosi' "src.backend" si importa lanciando lo script da ovunque
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from src.backend.database import async_session_factory # noqa: E402
|
||||
from src.backend.models.orm.measurement import Measurement # noqa: E402
|
||||
from src.backend.models.orm.production import ( # noqa: E402
|
||||
ProductionEvent, ProductionRun,
|
||||
)
|
||||
from src.backend.models.orm.recipe import Recipe, RecipeVersion # noqa: E402
|
||||
from src.backend.models.orm.setting import RecipeVersionAudit # noqa: E402
|
||||
from src.backend.models.orm.station import ( # noqa: E402
|
||||
Station, StationRecipeAssignment,
|
||||
)
|
||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask # noqa: E402
|
||||
from src.backend.models.orm.task_result import TaskResult # noqa: E402
|
||||
from src.backend.models.orm.user import User # noqa: E402
|
||||
from src.backend.services import auth_service # noqa: E402
|
||||
from src.backend.services.measurement_service import calculate_pass_fail # noqa: E402
|
||||
|
||||
CODES = ("FIERA-FLANGIA", "FIERA-ALBERO", "FIERA-DXF")
|
||||
STATION_CODE = "ST-DEFAULT" # STATION_CODE del pacchetto Windows (avvio.py PREDEFINITI)
|
||||
CAMERA_URL = "http://127.0.0.1:8765"
|
||||
CAMERA_SOURCE = "camera:601020" # Itala sul PC fiera
|
||||
SEED = 20261026 # seme fisso: dati ripetibili
|
||||
FUSO = ZoneInfo("Europe/Rome") # le misure sono salvate in UTC naive, l'orario di turno e' locale
|
||||
GIORNI_STORICO = 28 # circa 4 settimane
|
||||
|
||||
# (username, password, nome, ruoli, admin). Password "123" per tutti, chiesta da Adriano per la fiera (#85); il login non controlla la lunghezza.
|
||||
UTENTI = [
|
||||
("adriano", "123", "Adriano Dal Pastro",
|
||||
["Maker", "MeasurementTec", "Metrologist", "Supervisor"], True),
|
||||
("maker", "123", "Marco Ferrari (Maker)", ["Maker"], False),
|
||||
("capoturno", "123", "Luca Bianchi (Capoturno)",
|
||||
["Supervisor", "MeasurementTec"], False),
|
||||
("operatore", "123", "Giulia Rossi (Operatore)", ["MeasurementTec"], False),
|
||||
]
|
||||
|
||||
|
||||
def _quota(marker, descrizione, nominale, tol, unita="mm", dec=3):
|
||||
"""Quota simmetrica: tolleranza = nominale +/- tol, warning al 70% della tolleranza."""
|
||||
return dict(
|
||||
marker_number=marker, description=descrizione, nominal=nominale,
|
||||
ltl=round(nominale - tol, dec), lwl=round(nominale - 0.7 * tol, dec),
|
||||
uwl=round(nominale + 0.7 * tol, dec), utl=round(nominale + tol, dec),
|
||||
unit=unita,
|
||||
)
|
||||
|
||||
|
||||
# Profilo statistico di una quota: scostamento medio (in frazioni di tol), sigma (in
|
||||
# frazioni di tol) e deriva (frazione di tol aggiunta linearmente nell'ultima settimana).
|
||||
# Cpk ~ (1 - |scostamento|) / (3 * sigma): sigma 0.2 -> 1,67; 0.33 -> 1,0.
|
||||
FLANGIA = [
|
||||
# (task, quota, scostamento, sigma, deriva)
|
||||
(0, _quota(1, "Diametro esterno Ø80", 80.0, 0.05), 0.0, 0.20, 0.0), # Cpk ~1,67 capace
|
||||
(0, _quota(2, "Diametro foro centrale Ø25", 25.0, 0.02), 0.0, 0.33, 0.0), # Cpk ~1,0 al limite
|
||||
(1, _quota(3, "Spessore flangia", 12.0, 0.10), 0.0, 0.22, 0.55), # deriva ultima settimana
|
||||
(1, _quota(4, "Interasse fori Ø60", 60.0, 0.10), 0.10, 0.25, 0.0), # Cpk ~1,2
|
||||
(1, _quota(5, "Profondita' lamatura", 8.0, 0.15), 0.50, 0.244, 0.0), # ~2% fuori tolleranza
|
||||
]
|
||||
ALBERO = [
|
||||
(0, _quota(1, "Diametro cuscinetto Ø20 h6", 20.0, 0.010), 0.0, 0.20, 0.0), # Cpk ~1,67
|
||||
(0, _quota(2, "Diametro sede Ø25 j5", 25.0, 0.008), 0.0, 0.25, 0.0), # Cpk ~1,33
|
||||
(1, _quota(3, "Lunghezza totale", 150.0, 0.10), 0.0, 0.33, 0.0), # Cpk ~1,0
|
||||
(1, _quota(4, "Rugosita' Ra", 0.40, 0.20, unita="µm"), 0.20, 0.25, 0.0), # Cpk ~1,07
|
||||
]
|
||||
|
||||
|
||||
def _ora_utc(giorno: date, minuti_da_mezzanotte: int) -> datetime:
|
||||
"""Ora locale di officina -> datetime UTC naive, come le salva il backend."""
|
||||
locale = datetime.combine(giorno, time(0, 0), tzinfo=FUSO) + timedelta(minutes=minuti_da_mezzanotte)
|
||||
return locale.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _giorni_lavorativi(fine: date) -> list[date]:
|
||||
"""Lun-ven degli ultimi GIORNI_STORICO giorni, fino a `fine` compreso."""
|
||||
giorni = [fine - timedelta(days=i) for i in range(GIORNI_STORICO)]
|
||||
return sorted(g for g in giorni if g.weekday() < 5)
|
||||
|
||||
|
||||
def _slot_giornalieri(rng: random.Random, n: int) -> list[int]:
|
||||
"""n orari (minuti dalla mezzanotte) in 8-12 e 13-17, ogni ~30 min con un po' di scarto."""
|
||||
mezz_ore = [8 * 60 + 30 * i for i in range(8)] + [13 * 60 + 30 * i for i in range(8)]
|
||||
scelti = sorted(rng.sample(mezz_ore, n))
|
||||
return [m + rng.randint(0, 9) for m in scelti]
|
||||
|
||||
|
||||
async def _upsert_utenti(session: AsyncSession) -> dict[str, User]:
|
||||
"""Crea gli utenti o, se esistono, ne aggiorna password, nome, ruoli e flag admin."""
|
||||
utenti = {}
|
||||
for username, password, nome, ruoli, admin in UTENTI:
|
||||
esistente = (await session.execute(
|
||||
select(User).where(User.username == username)
|
||||
)).scalar_one_or_none()
|
||||
if esistente is None:
|
||||
utenti[username] = await auth_service.create_user(
|
||||
session, username=username, password=password, display_name=nome,
|
||||
roles=ruoli, is_admin=admin,
|
||||
)
|
||||
else:
|
||||
esistente.password_hash = auth_service.hash_password(password)
|
||||
esistente.display_name = nome
|
||||
esistente.roles = ruoli
|
||||
esistente.is_admin = admin
|
||||
esistente.active = True
|
||||
utenti[username] = esistente
|
||||
await session.flush()
|
||||
return utenti
|
||||
|
||||
|
||||
async def _upsert_stazione(session: AsyncSession, owner: User) -> Station:
|
||||
"""La stazione del pacchetto, con la camera Itala; se c'e' gia' ne aggiorna la camera."""
|
||||
stazione = (await session.execute(
|
||||
select(Station).where(Station.code == STATION_CODE)
|
||||
)).scalar_one_or_none()
|
||||
if stazione is None:
|
||||
stazione = Station(
|
||||
code=STATION_CODE, name="Postazione fiera", location="Stand",
|
||||
created_by=owner.id,
|
||||
)
|
||||
session.add(stazione)
|
||||
stazione.camera_url = CAMERA_URL
|
||||
stazione.camera_source = CAMERA_SOURCE
|
||||
await session.flush()
|
||||
return stazione
|
||||
|
||||
|
||||
async def _conta_e_cancella(session: AsyncSession, ricette: list[Recipe]) -> dict:
|
||||
"""Toglie le ricette FIERA-* e tutto cio' che e' misurato contro di esse. Ritorna i conteggi."""
|
||||
conti = {"misure": 0, "produzioni": 0, "eventi": 0}
|
||||
for ricetta in ricette:
|
||||
versioni = (await session.execute(
|
||||
select(RecipeVersion.id).where(RecipeVersion.recipe_id == ricetta.id)
|
||||
)).scalars().all()
|
||||
runs = (await session.execute(
|
||||
select(ProductionRun.id).where(ProductionRun.recipe_id == ricetta.id)
|
||||
)).scalars().all()
|
||||
if versioni:
|
||||
conti["misure"] += (await session.execute(
|
||||
delete(Measurement).where(Measurement.version_id.in_(versioni))
|
||||
)).rowcount
|
||||
await session.execute( # esiti dei task di queste versioni (FK sulle versioni)
|
||||
delete(TaskResult).where(TaskResult.version_id.in_(versioni))
|
||||
)
|
||||
if runs:
|
||||
conti["eventi"] += (await session.execute(
|
||||
delete(ProductionEvent).where(ProductionEvent.run_id.in_(runs))
|
||||
)).rowcount
|
||||
conti["produzioni"] += (await session.execute(
|
||||
delete(ProductionRun).where(ProductionRun.id.in_(runs))
|
||||
)).rowcount
|
||||
await session.execute(
|
||||
delete(StationRecipeAssignment).where(StationRecipeAssignment.recipe_id == ricetta.id)
|
||||
)
|
||||
await session.execute(
|
||||
delete(RecipeVersionAudit).where(RecipeVersionAudit.recipe_id == ricetta.id)
|
||||
)
|
||||
# Versioni, task e quote se ne vanno con la cascade ORM della ricetta.
|
||||
await session.delete(ricetta)
|
||||
await session.flush()
|
||||
return conti
|
||||
|
||||
|
||||
async def _nuova_ricetta(session, owner, stazione, *, code, name, description,
|
||||
interval, requires_serial, note_versione):
|
||||
"""Ricetta + versione 1 corrente + assegnazione alla stazione."""
|
||||
ricetta = Recipe(
|
||||
code=code, name=name, description=description, created_by=owner.id, active=True,
|
||||
measurement_interval_minutes=interval, requires_lot=True,
|
||||
requires_serial=requires_serial, allow_manual_input=True,
|
||||
)
|
||||
session.add(ricetta)
|
||||
await session.flush()
|
||||
versione = RecipeVersion(
|
||||
recipe_id=ricetta.id, version_number=1, is_current=True,
|
||||
created_by=owner.id, change_notes=note_versione,
|
||||
)
|
||||
session.add(versione)
|
||||
session.add(StationRecipeAssignment(
|
||||
station_id=stazione.id, recipe_id=ricetta.id, assigned_by=owner.id,
|
||||
))
|
||||
await session.flush()
|
||||
return ricetta, versione
|
||||
|
||||
|
||||
async def _task(session, versione, ordine, titolo, tipo, direttiva=None,
|
||||
descrizione=None, quote=()):
|
||||
task = RecipeTask(
|
||||
version_id=versione.id, order_index=ordine, title=titolo, task_type=tipo,
|
||||
directive=direttiva, description=descrizione,
|
||||
)
|
||||
session.add(task)
|
||||
await session.flush()
|
||||
sottotask = []
|
||||
for q in quote:
|
||||
st = RecipeSubtask(task_id=task.id, **q)
|
||||
session.add(st)
|
||||
sottotask.append(st)
|
||||
await session.flush()
|
||||
return task, sottotask
|
||||
|
||||
|
||||
async def _ricetta_meccanica(session, owner, stazione, *, code, name, description,
|
||||
interval, requires_serial, profilo, titoli_task):
|
||||
"""Ricetta con una nota iniziale e un task di misura per ogni gruppo di quote del profilo."""
|
||||
ricetta, versione = await _nuova_ricetta(
|
||||
session, owner, stazione, code=code, name=name, description=description,
|
||||
interval=interval, requires_serial=requires_serial,
|
||||
note_versione="Ricetta dimostrativa per la fiera",
|
||||
)
|
||||
await _task(session, versione, 0, "Preparazione del pezzo", "note",
|
||||
direttiva="Prima di misurare",
|
||||
descrizione="Pulire il pezzo e controllare che sia a **temperatura ambiente**.")
|
||||
sottotask_per_quota = {}
|
||||
for indice, titolo in enumerate(titoli_task):
|
||||
quote = [q for (t, q, *_r) in profilo if t == indice]
|
||||
_t, sottotask = await _task(
|
||||
session, versione, indice + 1, titolo, "measure",
|
||||
direttiva="Misurare le quote in sequenza", quote=quote,
|
||||
)
|
||||
for st in sottotask:
|
||||
sottotask_per_quota[st.marker_number] = st
|
||||
return ricetta, versione, sottotask_per_quota
|
||||
|
||||
|
||||
def _valore(rng, quota, scost, sigma, deriva, frazione_tempo):
|
||||
"""Un valore: normale attorno a nominale + scostamento (+ deriva nell'ultima settimana)."""
|
||||
tol = quota["utl"] - quota["nominal"]
|
||||
media = quota["nominal"] + tol * (scost + deriva * max(0.0, (frazione_tempo - 0.75) / 0.25))
|
||||
return round(rng.gauss(media, tol * sigma), 6)
|
||||
|
||||
|
||||
async def _storico(session, rng, fine, owner_ricetta, stazione, utenti, ricetta, versione,
|
||||
sottotask, profilo, pezzi_giorno, prefisso_lotto, prefisso_seriale):
|
||||
"""Una produzione chiusa per giorno lavorativo, con i suoi pezzi, eventi e misure."""
|
||||
giorni = _giorni_lavorativi(fine)
|
||||
operatore, capoturno = utenti["operatore"], utenti["capoturno"]
|
||||
task_ids = {} # marker -> id del task di misura
|
||||
for marker, st in sottotask.items():
|
||||
task_ids[marker] = st.task_id
|
||||
ordine_task = sorted(set(task_ids.values()))
|
||||
ultimo_task = ordine_task[-1]
|
||||
contatore = 0
|
||||
totale = 0
|
||||
for indice_giorno, giorno in enumerate(giorni):
|
||||
frazione = indice_giorno / max(1, len(giorni) - 1) # 0 = primo giorno, 1 = ieri
|
||||
n = rng.randint(*pezzi_giorno)
|
||||
slot = _slot_giornalieri(rng, n)
|
||||
lotto = f"{prefisso_lotto}{giorno:%y%m%d}"
|
||||
inizio, fine_run = _ora_utc(giorno, slot[0] - 5), _ora_utc(giorno, slot[-1] + 10)
|
||||
run = ProductionRun(
|
||||
station_id=stazione.id, recipe_id=ricetta.id, version_id=versione.id,
|
||||
operator_id=operatore.id, lot_number=lotto, status="closed",
|
||||
measurement_interval_minutes=ricetta.measurement_interval_minutes,
|
||||
cycle_count=n, started_at=inizio, closed_at=fine_run, closed_by=capoturno.id,
|
||||
active_station_id=None,
|
||||
)
|
||||
session.add(run)
|
||||
await session.flush()
|
||||
session.add(ProductionEvent(run_id=run.id, event_type="start", user_id=operatore.id,
|
||||
created_at=inizio))
|
||||
for numero, minuti in enumerate(slot, start=1):
|
||||
contatore += 1
|
||||
seriale = f"{prefisso_seriale}{contatore:05d}"
|
||||
istante = _ora_utc(giorno, minuti)
|
||||
for marker in sorted(sottotask):
|
||||
st = sottotask[marker]
|
||||
_t, quota, scost, sigma, deriva = next(p for p in profilo if p[1]["marker_number"] == marker)
|
||||
valore = _valore(rng, quota, scost, sigma, deriva, frazione)
|
||||
esito, scarto = calculate_pass_fail(valore, st)
|
||||
m = Measurement(
|
||||
subtask_id=st.id, version_id=versione.id, measured_by=operatore.id,
|
||||
value=valore, pass_fail=esito, deviation=scarto, lot_number=lotto,
|
||||
serial_number=seriale,
|
||||
input_method="usb_caliper" if rng.random() < 0.75 else "manual",
|
||||
input_duration_ms=rng.randint(2500, 9000),
|
||||
production_run_id=run.id,
|
||||
# un fuori tolleranza passa solo con l'ok del capoturno (come in produzione)
|
||||
supervisor_id=capoturno.id if esito == "fail" else None,
|
||||
authorised_at=istante + timedelta(minutes=1) if esito == "fail" else None,
|
||||
measured_at=istante + timedelta(seconds=20 * marker),
|
||||
)
|
||||
session.add(m)
|
||||
totale += 1
|
||||
# un task_measured per ogni task di misura non finale, poi il ciclo completato
|
||||
for tid in ordine_task:
|
||||
tipo = "cycle_completed" if tid == ultimo_task else "task_measured"
|
||||
session.add(ProductionEvent(
|
||||
run_id=run.id, event_type=tipo, user_id=operatore.id, task_id=tid,
|
||||
created_at=istante + timedelta(minutes=1),
|
||||
))
|
||||
session.add(ProductionEvent(run_id=run.id, event_type="close", user_id=capoturno.id,
|
||||
supervisor_id=capoturno.id, created_at=fine_run))
|
||||
await session.flush()
|
||||
return contatore, totale
|
||||
|
||||
|
||||
async def _ricetta_dxf(session, owner, stazione):
|
||||
"""Demo visione: un task dxf_compare, senza grafo (si crea in fiera dal pannello DXF)."""
|
||||
ricetta, versione = await _nuova_ricetta(
|
||||
session, owner, stazione, code="FIERA-DXF", name="Profilo DXF (visione)",
|
||||
description="Confronto di un profilo con il disegno DXF tramite la camera. "
|
||||
"La ricetta di visione si crea in fiera dal pannello «Crea ricetta da DXF».",
|
||||
interval=None, requires_serial=False, note_versione="Ricetta dimostrativa per la fiera",
|
||||
)
|
||||
await _task(session, versione, 0, "Preparazione del pezzo", "note",
|
||||
direttiva="Prima di misurare",
|
||||
descrizione="Appoggiare il pezzo sul piano della camera, **in piano**.")
|
||||
await _task(
|
||||
session, versione, 1, "Confronto profilo con DXF", "dxf_compare",
|
||||
direttiva="Acquisire e confrontare",
|
||||
descrizione="Lo scostamento massimo del profilo rispetto al DXF deve restare entro la tolleranza.",
|
||||
quote=[_quota(1, "Scostamento massimo profilo", 0.0, 0.15)],
|
||||
)
|
||||
return ricetta
|
||||
|
||||
|
||||
async def seed(replace: bool, dry_run: bool, fine: date) -> int:
|
||||
"""Esegue il seed in una sola transazione; con dry_run annulla tutto alla fine."""
|
||||
rng = random.Random(SEED)
|
||||
async with async_session_factory() as session:
|
||||
utenti = await _upsert_utenti(session)
|
||||
owner = utenti["adriano"]
|
||||
stazione = await _upsert_stazione(session, owner)
|
||||
|
||||
esistenti = list((await session.execute(
|
||||
select(Recipe).where(Recipe.code.in_(CODES))
|
||||
)).scalars().all())
|
||||
if esistenti and not replace:
|
||||
print(f"Ricette FIERA gia' presenti ({', '.join(r.code for r in esistenti)}): "
|
||||
"non duplico. Usa --replace per rifarle.")
|
||||
else:
|
||||
if esistenti:
|
||||
conti = await _conta_e_cancella(session, esistenti)
|
||||
print(f"--replace: rimosse {', '.join(r.code for r in esistenti)} "
|
||||
f"({conti['misure']} misure, {conti['produzioni']} produzioni, "
|
||||
f"{conti['eventi']} eventi)")
|
||||
|
||||
r1, v1, sub1 = await _ricetta_meccanica(
|
||||
session, owner, stazione, code="FIERA-FLANGIA", name="Flangia Ø80",
|
||||
description="Flangia tornita in alluminio: diametri, spessore, interasse fori "
|
||||
"e profondita' della lamatura. Lotto e seriale obbligatori.",
|
||||
interval=30, requires_serial=True, profilo=FLANGIA,
|
||||
titoli_task=["Misure diametrali", "Spessore e posizione fori"],
|
||||
)
|
||||
n1, m1 = await _storico(session, rng, fine, owner, stazione, utenti, r1, v1, sub1,
|
||||
FLANGIA, (10, 14), "LF", "FL")
|
||||
r2, v2, sub2 = await _ricetta_meccanica(
|
||||
session, owner, stazione, code="FIERA-ALBERO", name="Albero rettificato",
|
||||
description="Albero in acciaio rettificato: sedi dei cuscinetti, lunghezza e "
|
||||
"rugosita'. Tolleranze strette.",
|
||||
interval=30, requires_serial=False, profilo=ALBERO,
|
||||
titoli_task=["Diametri rettificati", "Lunghezza e rugosita'"],
|
||||
)
|
||||
n2, m2 = await _storico(session, rng, fine, owner, stazione, utenti, r2, v2, sub2,
|
||||
ALBERO, (8, 11), "LA", "AL")
|
||||
await _ricetta_dxf(session, owner, stazione)
|
||||
print(f"FIERA-FLANGIA: {n1} pezzi, {m1} misure; FIERA-ALBERO: {n2} pezzi, {m2} misure")
|
||||
|
||||
if dry_run:
|
||||
await session.rollback()
|
||||
print("--dry-run: nessuna modifica salvata.")
|
||||
else:
|
||||
await session.commit()
|
||||
print(f"Seed fiera completato (stazione {stazione.code}, utenti: "
|
||||
f"{', '.join(u[0] for u in UTENTI)}).")
|
||||
return 0
|
||||
|
||||
|
||||
def _factory_per_file(db: Path):
|
||||
"""Sessioni async su un file SQLite qualsiasi, con le stesse PRAGMA dell'app (FK attive)."""
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{db.resolve().as_posix()}", echo=False)
|
||||
|
||||
@event.listens_for(engine.sync_engine, "connect")
|
||||
def _pragma(dbapi_connection, _record) -> None:
|
||||
cur = dbapi_connection.cursor()
|
||||
cur.execute("PRAGMA foreign_keys=ON")
|
||||
cur.execute("PRAGMA busy_timeout=5000")
|
||||
cur.close()
|
||||
|
||||
return engine, async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
async def _main_async(args) -> int:
|
||||
global async_session_factory
|
||||
engine, async_session_factory = _factory_per_file(args.db)
|
||||
try:
|
||||
fine = date.fromisoformat(args.fine) if args.fine else date.today() - timedelta(days=1)
|
||||
return await seed(args.replace, args.dry_run, fine)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--db", required=True, type=Path, help="file SQLite con lo schema gia' creato")
|
||||
parser.add_argument("--replace", action="store_true",
|
||||
help="rimuove i dati FIERA-* (ricette, misure, produzioni) e li rifa'")
|
||||
parser.add_argument("--dry-run", action="store_true", help="esegue tutto e annulla")
|
||||
parser.add_argument("--fine", default=None, help="ultimo giorno dello storico AAAA-MM-GG (default: ieri)")
|
||||
args = parser.parse_args()
|
||||
if not args.db.is_file():
|
||||
print(f"File non trovato: {args.db}")
|
||||
return 2
|
||||
return asyncio.run(_main_async(args))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,286 @@
|
||||
"""Test delle rotte di acquisizione di scripts/camera_service.py (#87).
|
||||
|
||||
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
|
||||
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
|
||||
Copre: POST /camera/anteprima, GET/PUT /camera/parametri, POST /camera/parametri/salva,
|
||||
caricamento dei settaggi alla riapertura, alias GET /acquisizione.
|
||||
Sorgente regolabile finta (iniettata su open_source) e FolderCamera su PNG temporanei.
|
||||
"""
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from visionsuite.camera.camera_interface import CameraStateError
|
||||
|
||||
MODULO = Path(__file__).resolve().parents[1] / "camera_service.py"
|
||||
SORGENTE = "camera:finta"
|
||||
|
||||
|
||||
def _carica():
|
||||
spec = importlib.util.spec_from_file_location("camera_service_acq", MODULO)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
class Finta:
|
||||
"""Camera regolabile finta: stesso vocabolario di GenicamSource."""
|
||||
|
||||
DOMINI = {
|
||||
"esposizione": {"tipo": "numero", "min": 10.0, "max": 100000.0, "passo": 1.0},
|
||||
"esposizione_auto": {"tipo": "scelta", "scelte": ["Off", "Once", "Continuous"]},
|
||||
"guadagno": {"tipo": "numero", "min": 0.0, "max": 24.0},
|
||||
"guadagno_auto": {"tipo": "scelta", "scelte": ["Off", "Once", "Continuous"]},
|
||||
}
|
||||
|
||||
def __init__(self, supportati=None, rifiuta=()):
|
||||
self.valori = {"esposizione": 5000.0, "esposizione_auto": "Continuous",
|
||||
"guadagno": 1.0, "guadagno_auto": "Off"}
|
||||
self.supportati = set(supportati if supportati is not None else self.DOMINI)
|
||||
self.rifiuta = set(rifiuta)
|
||||
self.scritture = []
|
||||
self.caricato = None
|
||||
self.chiusa = False
|
||||
# metà bianca (255) e metà a 100: saturati 50%, media 177,5
|
||||
frame = np.full((1000, 2000, 3), 100, np.uint8)
|
||||
frame[:, :1000] = 255
|
||||
self.frame = frame
|
||||
|
||||
def supporta(self, nome):
|
||||
return nome in self.supportati
|
||||
|
||||
def dominio(self, nome):
|
||||
return {"capacita": nome, "valore": self.valori[nome], **self.DOMINI[nome]}
|
||||
|
||||
def leggi(self, nome):
|
||||
return self.valori[nome]
|
||||
|
||||
def scrivi(self, nome, valore, assesta=0):
|
||||
if nome in self.rifiuta:
|
||||
raise CameraStateError(f"non posso impostare {nome}: nodo in sola lettura")
|
||||
self.scritture.append((nome, valore, assesta))
|
||||
self.valori[nome] = valore
|
||||
|
||||
def salva_settaggi(self, percorso):
|
||||
Path(percorso).write_text(json.dumps(self.valori), encoding="utf-8")
|
||||
return dict(self.valori)
|
||||
|
||||
def carica_settaggi(self, percorso, forza=False):
|
||||
self.caricato = Path(percorso)
|
||||
self.valori.update(json.loads(self.caricato.read_text(encoding="utf-8")))
|
||||
return []
|
||||
|
||||
def grab(self, timeout=0):
|
||||
return self.frame.copy()
|
||||
|
||||
def close(self):
|
||||
self.chiusa = True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cartella(tmp_path):
|
||||
img = tmp_path / "foto"
|
||||
img.mkdir()
|
||||
for nome in ("a.png", "b.png"):
|
||||
cv2.imwrite(str(img / nome), np.full((48, 64, 3), 128, np.uint8))
|
||||
return img
|
||||
|
||||
|
||||
def _client(cartella, config, monkeypatch, finta):
|
||||
mod = _carica()
|
||||
monkeypatch.setattr(mod, "open_source", lambda *a, **k: finta)
|
||||
c = TestClient(mod.create_app(cartella, config))
|
||||
c.mod = mod
|
||||
return c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def finta():
|
||||
return Finta()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(cartella, tmp_path, monkeypatch, finta):
|
||||
with _client(cartella, tmp_path / "cfg", monkeypatch, finta) as c:
|
||||
c.finta = finta
|
||||
c.config = tmp_path / "cfg"
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_cartella(cartella, tmp_path):
|
||||
mod = _carica()
|
||||
with TestClient(mod.create_app(cartella, tmp_path / "cfg")) as c:
|
||||
yield c
|
||||
|
||||
|
||||
# ---- anteprima
|
||||
|
||||
def test_anteprima_jpeg_ridotta_e_header(client):
|
||||
r = client.post("/camera/anteprima", json={"source": SORGENTE, "lato_max": 500})
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"] == "image/jpeg"
|
||||
img = cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_COLOR)
|
||||
assert img.shape[:2] == (250, 500) # lato lungo 2000 -> 500
|
||||
assert r.headers["x-raddrizzato"] == "no"
|
||||
assert (r.headers["x-larghezza"], r.headers["x-altezza"]) == ("2000", "1000") # frame pieno
|
||||
assert r.headers["x-saturati-pct"] == "50.00"
|
||||
assert r.headers["x-media"] == "177.5"
|
||||
assert float(r.headers["x-nitidezza"]) > 0 # bordo netto fra le due metà
|
||||
|
||||
|
||||
def test_anteprima_mai_ingrandita(client_cartella):
|
||||
r = client_cartella.post("/camera/anteprima", json={"source": "camera:folder"})
|
||||
assert r.status_code == 200
|
||||
img = cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_COLOR)
|
||||
assert img.shape[:2] == (48, 64) # lato_max 1280 > 64: resta com'è
|
||||
assert r.headers["x-nitidezza"] == "0.0" # grigio uniforme
|
||||
assert r.headers["x-saturati-pct"] == "0.00"
|
||||
assert r.headers["x-media"] == "128.0"
|
||||
|
||||
|
||||
def test_anteprima_parametri_errati_400(client):
|
||||
for corpo in ({}, {"source": SORGENTE, "lato_max": 100}, {"source": SORGENTE, "lato_max": 5000},
|
||||
{"source": SORGENTE, "timeout_ms": 0}, {"source": SORGENTE, "grezzo": "si"}):
|
||||
assert client.post("/camera/anteprima", json=corpo).status_code == 400, corpo
|
||||
|
||||
|
||||
def test_anteprima_sorgente_inesistente_404(client_cartella):
|
||||
assert client_cartella.post("/camera/anteprima",
|
||||
json={"source": "camera:non-esiste"}).status_code == 404
|
||||
|
||||
|
||||
def test_anteprima_errore_camera_503_e_timeout_504(client):
|
||||
from visionsuite.camera.camera_interface import CameraGrabError
|
||||
|
||||
def rotta(timeout=0):
|
||||
raise CameraGrabError("Camera is not open")
|
||||
|
||||
client.finta.grab = rotta
|
||||
r = client.post("/camera/anteprima", json={"source": SORGENTE})
|
||||
assert r.status_code == 503 and r.json()["detail"] == "La camera non è aperta"
|
||||
|
||||
def lenta(timeout=0):
|
||||
raise TimeoutError("scaduto")
|
||||
|
||||
client.finta.grab = lenta
|
||||
assert client.post("/camera/anteprima", json={"source": SORGENTE}).status_code == 504
|
||||
|
||||
|
||||
# ---- parametri
|
||||
|
||||
def test_parametri_regolabile(client):
|
||||
r = client.get("/camera/parametri", params={"source": SORGENTE})
|
||||
assert r.status_code == 200
|
||||
j = r.json()
|
||||
assert j["source"] == SORGENTE and j["regolabile"] is True
|
||||
assert list(j["parametri"]) == ["esposizione", "esposizione_auto", "guadagno", "guadagno_auto"]
|
||||
assert j["parametri"]["esposizione"]["valore"] == 5000.0
|
||||
assert j["parametri"]["esposizione_auto"]["scelte"] == ["Off", "Once", "Continuous"]
|
||||
|
||||
|
||||
def test_parametri_solo_quelli_supportati(cartella, tmp_path, monkeypatch):
|
||||
with _client(cartella, tmp_path / "cfg", monkeypatch, Finta(supportati=["esposizione"])) as c:
|
||||
j = c.get("/camera/parametri", params={"source": SORGENTE}).json()
|
||||
assert j["regolabile"] is True and list(j["parametri"]) == ["esposizione"]
|
||||
|
||||
|
||||
def test_parametri_sorgente_non_regolabile(client_cartella):
|
||||
j = client_cartella.get("/camera/parametri", params={"source": "camera:folder"}).json()
|
||||
assert j == {"source": "camera:folder", "regolabile": False, "parametri": {}}
|
||||
|
||||
|
||||
def test_parametri_senza_source_400(client):
|
||||
assert client.get("/camera/parametri").status_code == 400
|
||||
|
||||
|
||||
def test_put_scrive_prima_gli_auto(client):
|
||||
r = client.put("/camera/parametri", json={"source": SORGENTE, "valori": {
|
||||
"esposizione": 8000, "guadagno": 3.5, "esposizione_auto": "Off", "guadagno_auto": "Off"}})
|
||||
assert r.status_code == 200
|
||||
assert client.finta.scritture == [
|
||||
("esposizione_auto", "Off", 3), ("guadagno_auto", "Off", 3),
|
||||
("esposizione", 8000, 3), ("guadagno", 3.5, 3)]
|
||||
j = r.json() # valori riletti dalla camera
|
||||
assert j["parametri"]["esposizione"]["valore"] == 8000
|
||||
assert j["parametri"]["esposizione_auto"]["valore"] == "Off"
|
||||
|
||||
|
||||
def test_put_nome_ignoto_400(client):
|
||||
r = client.put("/camera/parametri", json={"source": SORGENTE, "valori": {"frame_rate": 10}})
|
||||
assert r.status_code == 400
|
||||
assert client.finta.scritture == []
|
||||
for corpo in ({"source": SORGENTE}, {"source": SORGENTE, "valori": {}},
|
||||
{"source": SORGENTE, "valori": {"guadagno": True}}):
|
||||
assert client.put("/camera/parametri", json=corpo).status_code == 400, corpo
|
||||
|
||||
|
||||
def test_put_non_regolabile_409(client_cartella):
|
||||
r = client_cartella.put("/camera/parametri",
|
||||
json={"source": "camera:folder", "valori": {"esposizione": 1000}})
|
||||
assert r.status_code == 409
|
||||
assert r.json()["detail"] == "questa sorgente non ha parametri regolabili"
|
||||
|
||||
|
||||
def test_put_rifiuto_della_camera_422(cartella, tmp_path, monkeypatch):
|
||||
with _client(cartella, tmp_path / "cfg", monkeypatch, Finta(rifiuta=["guadagno"])) as c:
|
||||
r = c.put("/camera/parametri", json={"source": SORGENTE, "valori": {"guadagno": 5}})
|
||||
assert r.status_code == 422
|
||||
assert "guadagno" in r.json()["detail"]
|
||||
|
||||
|
||||
# ---- salvataggio e riapertura
|
||||
|
||||
def test_salva_crea_il_file(client):
|
||||
r = client.post("/camera/parametri/salva", json={"source": SORGENTE})
|
||||
assert r.status_code == 200
|
||||
j = r.json()
|
||||
assert j["source"] == SORGENTE and j["salvati"] is True
|
||||
atteso = client.config / "settaggi" / j["file"]
|
||||
assert atteso.is_file()
|
||||
assert j["file"].startswith("camera_finta-") and j["file"].endswith(".json")
|
||||
assert json.loads(atteso.read_text(encoding="utf-8"))["esposizione"] == 5000.0
|
||||
|
||||
|
||||
def test_salva_non_regolabile_409(client_cartella):
|
||||
r = client_cartella.post("/camera/parametri/salva", json={"source": "camera:folder"})
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
def test_riapertura_carica_i_settaggi(cartella, tmp_path, monkeypatch):
|
||||
config = tmp_path / "cfg"
|
||||
prima = Finta()
|
||||
with _client(cartella, config, monkeypatch, prima) as c:
|
||||
c.put("/camera/parametri", json={"source": SORGENTE, "valori": {"esposizione": 7777}})
|
||||
file = c.post("/camera/parametri/salva", json={"source": SORGENTE}).json()["file"]
|
||||
seconda = Finta() # servizio riavviato: camera nuova, valori di fabbrica
|
||||
with _client(cartella, config, monkeypatch, seconda) as c:
|
||||
assert seconda.caricato is None # niente apertura finché nessuno la chiede
|
||||
c.get("/camera/parametri", params={"source": SORGENTE})
|
||||
assert seconda.caricato == config / "settaggi" / file
|
||||
assert seconda.valori["esposizione"] == 7777
|
||||
|
||||
|
||||
def test_settaggi_rotti_non_bloccano_l_apertura(cartella, tmp_path, monkeypatch):
|
||||
class Rotta(Finta):
|
||||
def carica_settaggi(self, percorso, forza=False):
|
||||
raise ValueError("file illeggibile")
|
||||
|
||||
config = tmp_path / "cfg"
|
||||
with _client(cartella, config, monkeypatch, Finta()) as c:
|
||||
file = c.post("/camera/parametri/salva", json={"source": SORGENTE}).json()["file"]
|
||||
assert (config / "settaggi" / file).is_file()
|
||||
with _client(cartella, config, monkeypatch, Rotta()) as c:
|
||||
assert c.get("/camera/parametri", params={"source": SORGENTE}).status_code == 200
|
||||
|
||||
|
||||
# ---- pagina
|
||||
|
||||
def test_acquisizione_alias_della_pagina(client):
|
||||
a = client.get("/acquisizione")
|
||||
c = client.get("/calibrazione")
|
||||
assert a.status_code == 200 and a.text == c.text
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Test di scripts/seed_fiera.py (issue #85): il seed del PC della fiera.
|
||||
|
||||
Lo script non e' un modulo del pacchetto: si carica da file con importlib, come
|
||||
test_seed_collaudo. La parte "dato" gira sul DB in memoria dei test; l'ultimo test
|
||||
percorre invece la strada vera (file SQLite con `alembic upgrade head` + --db).
|
||||
"""
|
||||
import importlib.util
|
||||
import sqlite3
|
||||
from argparse import Namespace
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from src.backend.config import Settings, settings
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.recipe import Recipe
|
||||
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.services.auth_service import authenticate_user
|
||||
from src.backend.tests.conftest import TestSessionFactory, test_engine
|
||||
|
||||
_SCRIPT_PATH = Path(__file__).resolve().parents[3] / "scripts" / "seed_fiera.py"
|
||||
_ALEMBIC_INI = Path(__file__).resolve().parents[1] / "migrations" / "alembic.ini"
|
||||
FINE = date(2026, 10, 22) # data fissa: il test non dipende dal giorno in cui gira
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def seed_fiera(monkeypatch):
|
||||
spec = importlib.util.spec_from_file_location("seed_fiera", _SCRIPT_PATH)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
monkeypatch.setattr(module, "async_session_factory", TestSessionFactory)
|
||||
return module
|
||||
|
||||
|
||||
async def _utente(db_session, username) -> User:
|
||||
return (await db_session.execute(
|
||||
select(User).where(User.username == username)
|
||||
)).scalar_one()
|
||||
|
||||
|
||||
class TestSeedFiera:
|
||||
async def test_utenti_ruoli_e_login(self, seed_fiera, db_session):
|
||||
assert await seed_fiera.seed(False, False, FINE) == 0
|
||||
|
||||
attesi = {
|
||||
"adriano": ("123", True),
|
||||
"maker": ("123", False),
|
||||
"capoturno": ("123", False),
|
||||
"operatore": ("123", False),
|
||||
}
|
||||
for username, (password, admin) in attesi.items():
|
||||
utente = await authenticate_user(db_session, username, password)
|
||||
assert utente is not None, username
|
||||
assert utente.is_admin is admin
|
||||
assert (await _utente(db_session, "maker")).roles == ["Maker"]
|
||||
assert "Supervisor" in (await _utente(db_session, "capoturno")).roles
|
||||
assert (await _utente(db_session, "operatore")).roles == ["MeasurementTec"]
|
||||
assert {"Maker", "Metrologist"} <= set((await _utente(db_session, "adriano")).roles)
|
||||
|
||||
async def test_setup_risulta_completato(self, seed_fiera, client, monkeypatch):
|
||||
import src.backend.api.routers.setup as setup_mod
|
||||
monkeypatch.setattr(settings, "setup_password", "test-setup-password")
|
||||
monkeypatch.setattr(setup_mod, "engine", test_engine)
|
||||
monkeypatch.setattr(setup_mod, "async_session_factory", TestSessionFactory)
|
||||
|
||||
# prima del seed il frontend vede il setup da fare, dopo no
|
||||
assert (await client.get("/api/setup/status")).json() == {"initialised": False}
|
||||
await seed_fiera.seed(False, False, FINE)
|
||||
assert (await client.get("/api/setup/status")).json() == {"initialised": True}
|
||||
|
||||
async def test_stazione_e_ricette(self, seed_fiera, db_session):
|
||||
await seed_fiera.seed(False, False, FINE)
|
||||
|
||||
stazione = (await db_session.execute(
|
||||
select(Station).where(Station.code == "ST-DEFAULT")
|
||||
)).scalar_one()
|
||||
assert stazione.camera_url == "http://127.0.0.1:8765"
|
||||
assert stazione.camera_source == "camera:601020"
|
||||
|
||||
assegnate = (await db_session.execute(
|
||||
select(Recipe.code)
|
||||
.join(StationRecipeAssignment, StationRecipeAssignment.recipe_id == Recipe.id)
|
||||
.where(StationRecipeAssignment.station_id == stazione.id)
|
||||
)).scalars().all()
|
||||
assert sorted(assegnate) == ["FIERA-ALBERO", "FIERA-DXF", "FIERA-FLANGIA"]
|
||||
|
||||
# la ricetta DXF ha un task dxf_compare e nessun grafo di visione
|
||||
from src.backend.models.orm.task import RecipeTask
|
||||
dxf = (await db_session.execute(
|
||||
select(RecipeTask).where(RecipeTask.task_type == "dxf_compare")
|
||||
)).scalars().all()
|
||||
assert len(dxf) == 1 and dxf[0].vision_json is None
|
||||
|
||||
async def test_misure_nel_numero_atteso(self, seed_fiera, db_session):
|
||||
await seed_fiera.seed(False, False, FINE)
|
||||
|
||||
# ogni pezzo ha una misura per quota: pezzi 150-300 per ricetta
|
||||
for codice, quote in (("FIERA-FLANGIA", 5), ("FIERA-ALBERO", 4)):
|
||||
misure = (await db_session.execute(
|
||||
select(func.count(Measurement.id))
|
||||
.where(Measurement.version_id.in_(
|
||||
select(seed_fiera.RecipeVersion.id)
|
||||
.join(Recipe, Recipe.id == seed_fiera.RecipeVersion.recipe_id)
|
||||
.where(Recipe.code == codice)
|
||||
))
|
||||
)).scalar_one()
|
||||
assert misure % quote == 0
|
||||
assert 150 <= misure // quote <= 300, (codice, misure // quote)
|
||||
|
||||
# lun-ven soltanto, e le misure compaiono nelle produzioni
|
||||
giorni = (await db_session.execute(
|
||||
select(seed_fiera.ProductionRun.started_at)
|
||||
)).scalars().all()
|
||||
assert giorni and all(g.weekday() < 5 for g in giorni)
|
||||
senza_run = (await db_session.execute(
|
||||
select(func.count(Measurement.id)).where(Measurement.production_run_id.is_(None))
|
||||
)).scalar_one()
|
||||
assert senza_run == 0
|
||||
|
||||
async def test_statistiche_numeriche(self, seed_fiera, client, db_session):
|
||||
await seed_fiera.seed(False, False, FINE)
|
||||
adriano = await _utente(db_session, "adriano")
|
||||
from src.backend.services.auth_service import issue_api_key
|
||||
chiave = await issue_api_key(db_session, adriano)
|
||||
intestazioni = {"X-API-Key": chiave}
|
||||
|
||||
flangia = (await db_session.execute(
|
||||
select(Recipe).where(Recipe.code == "FIERA-FLANGIA")
|
||||
)).scalar_one()
|
||||
quote = (await client.get(
|
||||
"/api/statistics/subtasks", headers=intestazioni,
|
||||
params={"recipe_id": flangia.id},
|
||||
)).json()
|
||||
assert len(quote) == 5
|
||||
cpk = []
|
||||
for quota in quote:
|
||||
risposta = await client.get(
|
||||
"/api/statistics/capability", headers=intestazioni,
|
||||
params={"recipe_id": flangia.id, "subtask_id": quota["id"]},
|
||||
)
|
||||
assert risposta.status_code == 200
|
||||
d = risposta.json()
|
||||
for campo in ("mean", "std_dev", "cp", "cpk"):
|
||||
assert isinstance(d[campo], (int, float)), (quota["id"], campo)
|
||||
cpk.append(d["cpk"])
|
||||
# Cpk diversi per quota: una capace, una al limite o sotto
|
||||
assert max(cpk) > 1.4
|
||||
assert min(cpk) < 1.1
|
||||
|
||||
async def test_secondo_giro_non_duplica_e_replace_rifa(self, seed_fiera, db_session):
|
||||
async def conta():
|
||||
return [
|
||||
(await db_session.execute(select(func.count(t.id)))).scalar_one()
|
||||
for t in (Recipe, Measurement, User)
|
||||
]
|
||||
|
||||
await seed_fiera.seed(False, False, FINE)
|
||||
primo = await conta()
|
||||
await seed_fiera.seed(False, False, FINE)
|
||||
assert await conta() == primo # utenti aggiornati, ricette e misure non duplicate
|
||||
|
||||
await seed_fiera.seed(True, False, FINE)
|
||||
assert await conta() == primo # stessi numeri: il seme e' fisso
|
||||
|
||||
async def test_dry_run_non_salva(self, seed_fiera, db_session):
|
||||
await seed_fiera.seed(False, True, FINE)
|
||||
assert (await db_session.execute(select(func.count(User.id)))).scalar_one() == 0
|
||||
assert (await db_session.execute(select(func.count(Measurement.id)))).scalar_one() == 0
|
||||
|
||||
|
||||
async def test_file_sqlite_con_alembic(seed_fiera, tmp_path, monkeypatch):
|
||||
"""La strada vera: schema da `alembic upgrade head`, poi il seed con --db."""
|
||||
db = tmp_path / "tmf.db"
|
||||
url = f"sqlite+aiosqlite:///{db.as_posix()}"
|
||||
monkeypatch.setattr(Settings, "database_url", property(lambda self: url))
|
||||
# alembic fa asyncio.run(): fuori dal loop di pytest-asyncio, in un thread
|
||||
import asyncio
|
||||
await asyncio.to_thread(command.upgrade, Config(str(_ALEMBIC_INI)), "head")
|
||||
|
||||
argomenti = Namespace(db=db, replace=False, dry_run=False, fine=FINE.isoformat())
|
||||
assert await seed_fiera._main_async(argomenti) == 0
|
||||
|
||||
con = sqlite3.connect(db)
|
||||
try:
|
||||
assert con.execute("SELECT COUNT(*) FROM users WHERE is_admin=1").fetchone()[0] == 1
|
||||
assert con.execute("SELECT COUNT(*) FROM recipes").fetchone()[0] == 3
|
||||
assert con.execute("SELECT COUNT(*) FROM measurements").fetchone()[0] > 1000
|
||||
finally:
|
||||
con.close()
|
||||
@@ -17,6 +17,7 @@ import math
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import cv2 # #86: soglia/componenti connesse per la maschera del pezzo
|
||||
import numpy as np
|
||||
from visionsuite.core.image import Image
|
||||
|
||||
@@ -113,12 +114,41 @@ def _locate_reference(gray: np.ndarray, polarity: str) -> dict:
|
||||
return pezzo.found_poses[0].to_dict()
|
||||
|
||||
|
||||
def _candidate_anchors(gray: np.ndarray, dxf_path: Path, px_per_mm: float) -> list[tuple[float, float, float]]:
|
||||
def _maschera_pezzo(gray: np.ndarray, polarity: str, px_per_mm: float = 71.0) -> np.ndarray:
|
||||
"""Immagine con solo il pezzo: fuori dal blob staccato dal bordo e' sfondo (#86).
|
||||
|
||||
Otsu (INV se dark), componenti connesse, la piu' grande che NON tocca il
|
||||
bordo immagine (piastre del prisma, ombre e angoli neri della foto
|
||||
raddrizzata ci sono attaccati), dilatata di ~0,3 mm (minimo 15 px).
|
||||
"""
|
||||
flag = cv2.THRESH_BINARY_INV if polarity == "dark" else cv2.THRESH_BINARY
|
||||
_, binary = cv2.threshold(gray, 0, 255, flag | cv2.THRESH_OTSU)
|
||||
n, labels, stats, _ = cv2.connectedComponentsWithStats(binary, connectivity=8)
|
||||
h, w = gray.shape[:2]
|
||||
best, best_area = 0, 0
|
||||
for i in range(1, n): # 0 = sfondo
|
||||
x, y, cw, ch, area = (int(v) for v in stats[i])
|
||||
if x == 0 or y == 0 or x + cw >= w or y + ch >= h:
|
||||
continue # tocca il bordo immagine: non e' il pezzo
|
||||
if area > best_area:
|
||||
best, best_area = i, area
|
||||
if best == 0:
|
||||
raise CostruttoreError("pezzo non trovato: nessun blob staccato dal bordo")
|
||||
raggio = max(15, int(round(0.3 * px_per_mm))) # margine in funzione della scala
|
||||
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (2 * raggio + 1, 2 * raggio + 1))
|
||||
zona = cv2.dilate((labels == best).astype(np.uint8), kernel) > 0
|
||||
sfondo = 255 if polarity == "dark" else 0
|
||||
return np.where(zona, gray, np.uint8(sfondo)).astype(np.uint8)
|
||||
|
||||
|
||||
def _candidate_anchors(gray: np.ndarray, dxf_path: Path, px_per_mm: float,
|
||||
polarity: str = "dark") -> list[tuple[float, float, float]]:
|
||||
"""Pose (riga, col, angolo) del disegno sull'immagine: ICP da 4 orientamenti.
|
||||
|
||||
Il disegno e' in px (origine DXF, y capovolta); si parte da ognuno dei 4
|
||||
angoli con i centroidi sovrapposti e l'ICP rifinisce. Ritorna le posa trovate
|
||||
(anche per orientamenti che non convergono: le giudica la ricetta eseguita).
|
||||
Centroide e ICP lavorano sull'immagine mascherata sul solo pezzo (#86).
|
||||
"""
|
||||
from visionsuite.calibration import ScaleCalibration
|
||||
from visionsuite.dxf.compare import DXFCompare, _stack
|
||||
@@ -126,6 +156,7 @@ def _candidate_anchors(gray: np.ndarray, dxf_path: Path, px_per_mm: float) -> li
|
||||
|
||||
cmp_ = DXFCompare(dxf_path, ScaleCalibration(px_per_mm=px_per_mm))
|
||||
drawing = _stack(cmp_.drawing_contours())
|
||||
gray = _maschera_pezzo(gray, polarity, px_per_mm) # #86: via piastre, ombre, angoli neri
|
||||
measured = _stack(cmp_.measured_contours(Image(gray)))
|
||||
if len(drawing) < 3:
|
||||
raise CostruttoreError("DXF senza geometria utilizzabile")
|
||||
@@ -134,18 +165,22 @@ def _candidate_anchors(gray: np.ndarray, dxf_path: Path, px_per_mm: float) -> li
|
||||
dc, mc = drawing.mean(axis=0), measured.mean(axis=0)
|
||||
|
||||
anchors = []
|
||||
errori = [] # #86: motivo di ogni orientamento caduto, per la diagnosi in campo
|
||||
for theta in _START_ANGLES:
|
||||
# traslazione che porta il centroide del disegno ruotato sul misurato
|
||||
rr, cc = Pose(0.0, 0.0, theta).apply(dc[0], dc[1])
|
||||
start = Pose(float(mc[0] - rr), float(mc[1] - cc), theta)
|
||||
try:
|
||||
result = cmp_.compare(Image(gray), initial_pose=start)
|
||||
except Exception: # noqa: BLE001 - un orientamento che cade non ferma gli altri
|
||||
except Exception as exc: # noqa: BLE001 - un orientamento che cade non ferma gli altri
|
||||
errori.append(f"{theta:g}gradi: {type(exc).__name__}: {exc}") # #86
|
||||
continue
|
||||
# transform: misurato -> disegno; la posa del disegno e' l'inversa
|
||||
m = np.linalg.inv(result.transform)
|
||||
angle = math.degrees(math.atan2(m[1, 0], m[0, 0]))
|
||||
anchors.append((float(m[0, 2]), float(m[1, 2]), angle))
|
||||
if not anchors and errori: # #86: nessuna ancora -> il motivo risale al chiamante
|
||||
raise CostruttoreError("disegno non sovrapponibile al pezzo (ICP fallito): " + "; ".join(errori))
|
||||
return anchors
|
||||
|
||||
|
||||
@@ -199,7 +234,7 @@ def build_dxf_compare(
|
||||
raise CostruttoreError("DXF illeggibile: nessuna entita' geometrica")
|
||||
|
||||
reference_pose = _locate_reference(frame, polarity)
|
||||
anchors = _candidate_anchors(frame, dxf_path, px_per_mm)
|
||||
anchors = _candidate_anchors(frame, dxf_path, px_per_mm, polarity) # #86
|
||||
if not anchors:
|
||||
raise CostruttoreError("disegno non sovrapponibile al pezzo (ICP fallito)")
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ from src.vision.costruttori import (
|
||||
CALIBRATION_FILE,
|
||||
DXF_FILE,
|
||||
CostruttoreError,
|
||||
_candidate_anchors,
|
||||
_maschera_pezzo,
|
||||
build_dxf_compare,
|
||||
)
|
||||
from src.vision.runner import run_graph
|
||||
@@ -112,3 +114,37 @@ def test_dxf_illeggibile_solleva():
|
||||
def test_parametri_ricetta_invalidi(kwargs):
|
||||
with pytest.raises(CostruttoreError):
|
||||
build_dxf_compare(a_scene(), SAMPLE_DXF.read_bytes(), **kwargs)
|
||||
|
||||
|
||||
def test_maschera_pezzo_tiene_solo_il_blob_staccato_dal_bordo():
|
||||
# #86: pezzo scuro staccato + rettangoli scuri attaccati al bordo + angolo nero
|
||||
img = np.full((500, 600), 230, np.uint8)
|
||||
img[200:300, 250:400] = 20 # il pezzo
|
||||
img[0:60, 100:220] = 15 # attaccato al bordo alto
|
||||
img[420:500, 300:420] = 15 # attaccato al bordo basso
|
||||
img[0:80, 0:80] = 0 # angolo nero (borderValue=0 del raddrizzamento)
|
||||
out = _maschera_pezzo(img, "dark", px_per_mm=4.0)
|
||||
assert (out[200:300, 250:400] == 20).all() # il pezzo resta
|
||||
assert (out[0:60, 100:220] == 255).all() # i rettangoli sono sfondo
|
||||
assert (out[420:500, 300:420] == 255).all()
|
||||
assert (out[0:80, 0:80] == 255).all() # angolo nero cancellato
|
||||
|
||||
|
||||
def test_maschera_pezzo_senza_blob_staccato_solleva():
|
||||
img = np.full((200, 200), 230, np.uint8)
|
||||
img[:, 0:50] = 10 # solo una fascia attaccata al bordo
|
||||
with pytest.raises(CostruttoreError, match="staccato dal bordo"):
|
||||
_maschera_pezzo(img, "dark")
|
||||
|
||||
|
||||
def test_ricetta_dxf_icp_fallito_riporta_il_motivo(monkeypatch, tmp_path):
|
||||
# #86: l'errore "ICP fallito" porta il messaggio degli orientamenti caduti
|
||||
from visionsuite.dxf.compare import DXFCompare
|
||||
|
||||
def boom(self, *a, **k):
|
||||
raise ValueError("negative dimensions are not allowed")
|
||||
|
||||
monkeypatch.setattr(DXFCompare, "compare", boom)
|
||||
with pytest.raises(CostruttoreError, match="ICP fallito.*negative dimensions"):
|
||||
_candidate_anchors(a_scene(), SAMPLE_DXF, PX_PER_MM)
|
||||
|
||||
|
||||
Vendored
+1
-1
Submodule vendor/visionsuite updated: 2e73691cc3...e0d19dee7e
Reference in New Issue
Block a user