Compare commits

...

37 Commits

Author SHA1 Message Date
Adriano f504c86491 Acquisizione: immagine da file come sorgente, riquadri grezza e calibrata affiancati (Refs #92)
Pagina: Sorgente in cima alla tab Acquisizione con carica/rimuovi immagine,
riquadro Calibrata con casella, calibrazione sempre dal frame grezzo della
sorgente, ciclo anteprima sospeso nella tab Calibrazione.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 16:27:20 +02:00
Adriano 8eb80e20a8 Acquisizione: elenco calibrazioni a schede, parametri in lettura (Refs #91)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 16:14:37 +02:00
Adriano b2f82fe779 merge #91 pagina: tab Acquisizione / Calibrazione 2026-10-24 16:10:25 +02:00
Adriano 3c5c5f9bc5 merge #91 servizio: calibrazioni con nome, archivio unico 2026-10-24 16:10:25 +02:00
Adriano a5e8472e88 acquisitore: rotte per calibrazioni con nome, caricamento con controllo risoluzione (Refs #91)
/camera/calibrate non salva piu' (calcolo_id in memoria, ultimi 8); nuove rotte
GET/POST/DELETE /camera/calibrazioni e PUT /camera/calibrazione-attiva (409 se la
risoluzione non combacia col frame della sorgente); DELETE /camera/calibration ora
scollega. Test: rotte nuove e test esistenti aggiornati (save=true -> salva con nome).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 16:10:10 +02:00
Adriano d6cee498ad Acquisizione: pagina a due tab, calibrazioni salvate con nome e carica per sorgente (Refs #91)
Tab Acquisizione | Calibrazione con anteprima sempre visibile; il calcolo non salva piu',
Salva usa calcolo_id e un nome (conferma di sovrascrittura al secondo clic);
elenco unico con Carica / Scollega / Elimina e segno di risoluzione diversa.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 16:08:02 +02:00
Adriano e7d5489438 acquisitore: archivio unico di calibrazioni con nome, migrazione dal formato per sorgente (Refs #91)
Archivio: libreria calibrazioni/<nome>-<sha>.json (+npz), attive.json per sorgente,
salva_nome/leggi_nome/elimina_nome/attiva/scollega/attiva_di/elenco; leggi e
leggi_mappa risolvono la calibrazione attiva. Le rotte seguono nel commit successivo.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 16:06:05 +02:00
Adriano 828471d27a Acquisizione: anteprima a pausa minima 100 ms (Refs #90)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 15:49:17 +02:00
Adriano cf64a247be acquisitore: frame sempre piu' recente (Refs #89)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 15:44:38 +02:00
Adriano 813b66d0c5 Acquisizione: parametri camera con un decimale nelle caselle (#87)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 15:28:59 +02:00
Adriano 8c6d6c1630 sviluppo.ps1: -EscludiCamere; bump visionsuite VS_CAMERA_ESCLUDI (TieVision-Core#339)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 15:25:13 +02:00
Adriano 1afaaa0bae packaging: sviluppo.ps1, TMF da sorgente senza build Nuitka (#87 #88)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 15:09:33 +02:00
Adriano cb66371a5b Stazioni: x-cloak rimesso sulle modali, tolto per sbaglio (#88)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 14:58:23 +02:00
Adriano 390d6210d0 Stazioni: sorgenti camera lette dall'acquisitore, link Acquisizione (#88)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 14:51:22 +02:00
Adriano 2760623b53 Merge remote-tracking branch 'origin/lavoro/daitarn-tmf-acq-pagina' into lavoro/daitarn-tmf-dxfcal 2026-10-24 14:41:55 +02:00
Adriano 13eeec7119 Acquisizione: pagina, attributo hidden rispettato, indicatori su una riga, mirino a 2 px (#87)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 14:41:25 +02:00
Adriano 0629f1b5ec Acquisizione: pagina nuova con anteprima, parametri camera e stile TMF (#87)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 14:38:39 +02:00
Adriano 9eb5942459 Acquisitore: anteprima, parametri camera e settaggi di default (#87, piano A)
Rotte nuove nel servizio camera: POST /camera/anteprima (JPEG ridotto con
nitidezza, saturati e media sul frame pieno), GET/PUT /camera/parametri
(esposizione e guadagno con i relativi auto), POST /camera/parametri/salva.
I settaggi salvati vengono ricaricati alla prima apertura della camera.
GET /acquisizione serve la stessa pagina di /calibrazione.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 14:36:18 +02:00
Adriano 11c7d2e98b DXF compare: ancore ICP sul solo pezzo staccato dal bordo, errore ICP con motivo (#86)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 13:54:25 +02:00
Adriano 88bba213e2 bump vendor/visionsuite: guardia overlay fuori immagine (TieVision-Core#337)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 13:53:01 +02:00
Adriano 8ce7b489d6 Merge branch 'lavoro/daitarn-tmf-seedfiera' into lavoro/daitarn-tmf-dxfcal 2026-10-24 10:17:47 +02:00
Adriano bc90d327f1 seed fiera: password 123 per tutti, chiesta da Adriano (#85)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 10:17:45 +02:00
Adriano e46965cb51 Merge branch 'lavoro/daitarn-tmf-seedfiera' into lavoro/daitarn-tmf-dxfcal 2026-10-24 00:55:21 +02:00
Adriano c554fcfda7 seed fiera: commento sulle password corretto (#85)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 00:55:20 +02:00
Adriano 08e46e335b Merge branch 'lavoro/daitarn-tmf-calibdensa' into lavoro/daitarn-tmf-dxfcal 2026-10-24 00:49:52 +02:00
Adriano e38e1a2eaf Calibrazione densa: finestra sub-pixel ristretta al bordo, test su scacchiera continua (#84)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 00:00:36 +02:00
Adriano 7fa5bacdfe Acquisitore: calibrazione densa su C2/grab/pagina, test (#84)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-23 23:56:15 +02:00
Adriano 896a30b376 Calibrazione densa da una foto: griglia, mappa di distorsione, archivio .npz (#84)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-23 23:53:48 +02:00
Adriano 13dbc8f13b Merge branch 'lavoro/daitarn-tmf-dxfcal-frontend' into lavoro/daitarn-tmf-dxfcal 2026-10-23 23:52:00 +02:00
Adriano 8925968f34 Merge branch 'lavoro/daitarn-tmf-dxfcal-backend' into lavoro/daitarn-tmf-dxfcal 2026-10-23 23:52:00 +02:00
Adriano 2959b79fa2 Merge branch 'lavoro/daitarn-tmf-dxfcal-worker' into lavoro/daitarn-tmf-dxfcal 2026-10-23 23:52:00 +02:00
Adriano 990b5d0db8 Seed fiera: utenti, stazione, ricette e storico misure (#85)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-23 23:51:00 +02:00
Adriano 1bfcfaa5f2 Ricetta DXF da camera: pannello editor, stato calibrazione stazioni, proxy camera (#83)
Rotte maker: elenco stazioni con camera, grab per stazione, stato calibrazione
dall'acquisitore (C1), inoltro a B3. Pagina stazioni: stato e link «Calibra».
Test con api_client e requests finti; traduzioni IT/EN.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 22:51:11 +02:00
Adriano ad6ce5d11d Backend: ricetta DXF compare da camera e calibrazione letta dall'acquisitore (#83)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 22:44:15 +02:00
Adriano 351739ec36 Acquisitore: calibrazione a scacchiera per sorgente, rotte e pagina /calibrazione (#83)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 22:43:32 +02:00
Adriano 9f2d624b4f Worker: endpoint /recipes/dxf-compare con test (#83)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 22:40:08 +02:00
Adriano 9fe82d7cef Costruttore ricetta DXF compare dal campo, con test (#83)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 22:39:21 +02:00
30 changed files with 6782 additions and 50 deletions
+53
View File
@@ -0,0 +1,53 @@
<#
Cosa fa: avvia TieMeasureFlow DA SORGENTE su una macchina di sviluppo Windows, senza la build
Nuitka (che richiede ~20 minuti): stessi quattro server e stesse porte dell'exe (8780 web,
8000 backend, 8100 visione, 8765 camera), stessa cartella dati %PROGRAMDATA%\Tielogic\TieMeasureFlow.
Prima dell'avvio: ambiente Python 3.13 in build\venv313 (uv sync, solo con -Sincronizza o se manca),
tailwind.css e traduzioni .mo ricompilati, DLL di WeasyPrint in build\gtk (scaricate con
pdf\prendi-gtk.ps1 se mancano).
Come si usa (dalla radice del repo):
powershell -ExecutionPolicy Bypass -File packaging\windows\sviluppo.ps1 [-Sincronizza] [-Ascolto 0.0.0.0] [-EscludiCamere OECS]
Dopo una modifica: Ctrl+C e rilancio (pochi secondi). Template e JS non richiedono altro;
classi Tailwind nuove e msgid nuovi vengono ricompilati a ogni avvio.
Dipende da: uv, npx (Node), la licenza tiemeasureflow di QUESTA macchina in
%PROGRAMDATA%\Tielogic\licenze\tiemeasureflow.json (impronta: avvio.py --impronta).
#>
param(
[switch]$Sincronizza, # forza uv sync (dipendenze cambiate)
[string]$Ascolto = '127.0.0.1', # indirizzo di ascolto della UI web ($Host e' riservato in PowerShell)
[string]$EscludiCamere = '' # backend nativi da saltare (TieVision-Core#339): OECS se c'e' il runtime OECS ma in fiera si usa GenTL
)
$ErrorActionPreference = 'Stop'
$radice = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$env:UV_PROJECT_ENVIRONMENT = Join-Path $radice 'build\venv313' # stesso ambiente della build
$py = Join-Path $env:UV_PROJECT_ENVIRONMENT 'Scripts\python.exe'
if ($Sincronizza -or -not (Test-Path $py)) {
uv sync --python 3.13 --frozen --extra server --extra client --extra vision-worker --group windows
if ($LASTEXITCODE -ne 0) { throw "uv sync fallito" }
}
# CSS e traduzioni: non sono nel repo, come in build.ps1
$flask = Join-Path $radice 'src\frontend\flask_app'
Push-Location $flask
try {
npx --yes tailwindcss@3.4.19 -i static/css/input.css -o static/css/tailwind.css --minify
if ($LASTEXITCODE -ne 0) { throw "Tailwind fallito" }
& (Join-Path $env:UV_PROJECT_ENVIRONMENT 'Scripts\pybabel.exe') -q compile -d translations
if ($LASTEXITCODE -ne 0) { throw "pybabel compile fallito" }
} finally { Pop-Location }
# DLL di WeasyPrint fuori dalla radice (gtk\ nella radice non e' ignorata da git): build\gtk
$gtk = Join-Path $radice 'build\gtk'
if (-not (Test-Path (Join-Path $gtk '*.dll'))) {
powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'pdf\prendi-gtk.ps1') -Destinazione $gtk
if ($LASTEXITCODE -ne 0) { throw "prendi-gtk.ps1 fallito" }
}
$env:WEASYPRINT_DLL_DIRECTORIES = $gtk
if ($EscludiCamere) { $env:VS_CAMERA_ESCLUDI = $EscludiCamere } # #339: stesso percorso camera del PC fiera
Set-Location $radice
& $py (Join-Path $PSScriptRoot 'avvio.py') --senza-browser --senza-finestre --host $Ascolto # errori in console, niente MessageBox
exit $LASTEXITCODE
+781
View File
@@ -0,0 +1,781 @@
"""Calibrazione a scacchiera dell'acquisitore (TieMeasureFlow, #83).
Cosa fa: da una foto di scacchiera piana calcola la scala (px/mm) con
TieVision-Core (visionsuite.calibration), e conserva un risultato per sorgente
camera in un JSON (scrittura atomica).
#91: le calibrazioni stanno in un archivio UNICO, salvate con nome (classe Archivio);
una sorgente ne ha una attiva (attive.json) e il formato vecchio (una per sorgente)
si migra da solo.
#84: `calibra_scacchiera_densa(pixels, square_mm)` calibra da UNA foto di
scacchiera che riempie tutto il campo (ottica telecentrica): trova da sola la
griglia, costruisce la mappa di distorsione (DistortionMap) e il mm/px
dell'immagine raddrizzata. L'Archivio salva la mappa accanto al JSON (.npz) e
`raddrizza()` la applica ai frame.
Uso: importato da scripts/camera_service.py (rotte /camera/calibrate e
/camera/calibration); `calibra_scacchiera(gray, cols, rows, square_mm)` e
`calibra_scacchiera_densa(gray, square_mm)` sono logica pura, senza FastAPI,
e si provano da sole.
Dipende da: numpy, opencv, visionsuite (vs-core). Niente altro (no scipy).
"""
from __future__ import annotations
import base64
import hashlib
import json
import math
import os
import re
import tempfile
import threading
import unicodedata # #91 caratteri di controllo nei nomi
from collections import deque
from datetime import datetime, timezone
from pathlib import Path
from typing import NamedTuple
import cv2
import numpy as np
from visionsuite.calibration import calibrate_planar, find_chessboard
from visionsuite.calibration.distortion_map import DistortionMap # #84 mappa densa
from visionsuite.core.image import Image
class CalibrazioneError(ValueError):
"""Parametri invalidi o scacchiera non trovata: input sbagliato, non un guasto (422)."""
def _gray_u8(pixels: np.ndarray) -> np.ndarray:
"""Immagine -> grigio uint8 2D."""
px = np.asarray(pixels)
if px.ndim == 3:
px = cv2.cvtColor(px, cv2.COLOR_BGRA2GRAY if px.shape[2] == 4 else cv2.COLOR_BGR2GRAY)
if px.dtype != np.uint8:
# float/uint16 -> scala sul massimo, come fa vs-core
m = float(px.max()) if px.size else 1.0
px = np.clip(px.astype(np.float32) / max(m, 1e-9) * 255, 0, 255).astype(np.uint8)
return np.ascontiguousarray(px)
def calibra_scacchiera(pixels, cols: int, rows: int, square_mm: float) -> dict:
"""Scala (px/mm) da una foto di scacchiera piana. cols/rows = angoli INTERNI.
Ritorna {px_per_mm, mm_per_px, rms_px, n_corners, cols, rows, square_mm,
image_width, image_height, scale_spread_pct, metodo, distorsione,
overlay_png (b64)}.
"""
if cols < 2 or rows < 2:
raise CalibrazioneError("cols e rows (angoli interni) devono essere almeno 2")
if not (square_mm > 0 and math.isfinite(square_mm)):
raise CalibrazioneError("square_mm deve essere > 0")
frame = _gray_u8(pixels)
image = Image(frame)
try:
image_pts, world_pts = find_chessboard(image, (cols, rows), square_mm)
except RuntimeError as exc:
# find_chessboard solleva RuntimeError quando la scacchiera non c'e'
raise CalibrazioneError(
f"scacchiera {cols}x{rows} non trovata nell'immagine: {exc}") from exc
model = calibrate_planar(image, image_pts, world_pts) # omografia pura
# Scala al centro della scacchiera (puo' non stare al centro immagine)
x0, y0 = image_pts.min(axis=0)
x1, y1 = image_pts.max(axis=0)
mm_per_px = model.pixel_size((x0 + x1) / 2.0, (y0 + y1) / 2.0)
if not (mm_per_px > 0 and math.isfinite(mm_per_px)):
raise CalibrazioneError("calibrazione degenere: scala non valida")
# Scarto massimo % della scala ai quattro angoli dell'area della scacchiera
# rispetto al centro: ottica telecentrica/piana ~ 0, prospettiva = grande.
spread = max(abs(model.pixel_size(x, y) - mm_per_px) / mm_per_px * 100.0
for x in (x0, x1) for y in (y0, y1))
# Overlay: angoli disegnati sull'immagine (PNG base64)
bgr = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR)
cv2.drawChessboardCorners(
bgr, (cols, rows), image_pts.reshape(-1, 1, 2).astype(np.float32), True)
ok, png = cv2.imencode(".png", bgr)
if not ok:
raise RuntimeError("PNG dell'overlay non codificabile")
h, w = frame.shape
return {
"px_per_mm": 1.0 / mm_per_px,
"mm_per_px": mm_per_px,
"rms_px": float(model.rms_reprojection),
"n_corners": int(len(image_pts)),
"cols": int(cols),
"rows": int(rows),
"square_mm": float(square_mm),
"image_width": int(w),
"image_height": int(h),
"scale_spread_pct": float(spread),
"metodo": "planare", # #84: il metodo con cols/rows resta quello di prima
"distorsione": False,
"overlay_png": base64.b64encode(png.tobytes()).decode("ascii"),
}
# ---- #84: calibrazione densa (scacchiera che riempie il campo, griglia non nota)
# #84: findChessboardCornersSBWithMeta (verificato su opencv 5.0.0) su una scacchiera
# grande quanto l'immagine trova solo frammenti (2064x1544 con quadrati da 40 px:
# 5x5, 28x6...). Per questo la griglia si cerca qui: sellini della Hessiana a piu'
# scale + crescita del reticolo; poi cornerSubPix.
_SIGME = (1.5, 2.2, 3.2, 4.5, 6.5, 9.0, 13.0) # scale dei sellini (passo ~ 5..120 px)
_MAX_CANDIDATI = 6000
def _candidati(gray: np.ndarray, sigma: float) -> np.ndarray:
"""Massimi locali del 'selliness' (-det Hessiana) = angoli di scacchiera, (n,2) x,y."""
b = cv2.GaussianBlur(gray.astype(np.float32), (0, 0), sigma)
ixx = cv2.Sobel(b, cv2.CV_32F, 2, 0, ksize=3)
iyy = cv2.Sobel(b, cv2.CV_32F, 0, 2, ksize=3)
ixy = cv2.Sobel(b, cv2.CV_32F, 1, 1, ksize=3)
r = ixy * ixy - ixx * iyy # >0 solo dove l'immagine ha un sellino
massimo = float(r.max())
if massimo <= 0:
return np.zeros((0, 2))
k = 2 * int(math.ceil(1.5 * sigma)) + 1
picchi = (r >= cv2.dilate(r, np.ones((k, k), np.uint8))) & (r > 0.1 * massimo)
ys, xs = np.nonzero(picchi)
if len(xs) > _MAX_CANDIDATI: # tiene i piu' forti
forti = np.argsort(r[ys, xs])[::-1][:_MAX_CANDIDATI]
ys, xs = ys[forti], xs[forti]
return np.stack([xs, ys], axis=1).astype(np.float64)
def _e_angolo_x(lisc: np.ndarray, delta: float, p: np.ndarray, u: np.ndarray, v: np.ndarray) -> bool:
"""True se attorno a p i quattro quadranti alternano chiaro/scuro (vero angolo X).
Scarta le giunzioni a L/T al bordo della scacchiera (3 quadranti uguali), che
il selliness da solo accetta e che spostano la griglia.
"""
h, w = lisc.shape
campioni = []
for q in (0.25 * (u + v), -0.25 * (u + v), 0.25 * (u - v), -0.25 * (u - v)):
x, y = int(round(p[0] + q[0])), int(round(p[1] + q[1]))
if not (0 <= x < w and 0 <= y < h):
return False
campioni.append(float(lisc[y, x]))
s1, s2, t1, t2 = campioni
return (min(s1, s2) > max(t1, t2) + delta) or (max(s1, s2) < min(t1, t2) - delta)
def _cresci_reticolo(pts: np.ndarray, centro: tuple[float, float],
lisc: np.ndarray, delta: float) -> dict | None:
"""Assegna un indice (riga, colonna) ai candidati che stanno su un reticolo.
Parte dal candidato piu' vicino al centro, ricava i due vettori di passo dai
vicini e cresce a macchia d'olio: la base si aggiorna a ogni passo, quindi
segue una distorsione lenta. Ritorna {(i, j): indice candidato} o None.
"""
if len(pts) < 9:
return None
seme = int(np.argmin(np.hypot(pts[:, 0] - centro[0], pts[:, 1] - centro[1])))
d = np.hypot(pts[:, 0] - pts[seme, 0], pts[:, 1] - pts[seme, 1])
vicini = np.argsort(d)[1:9]
u = pts[vicini[0]] - pts[seme]
lu = float(np.hypot(*u))
v = None
for k in vicini[1:]:
w = pts[k] - pts[seme]
lw = float(np.hypot(*w))
# il secondo vettore: quasi ortogonale al primo e di lunghezza simile
if lu > 0 and lw > 0 and abs(float(u @ w)) / (lu * lw) < 0.4 and lw < 1.5 * lu:
v = w
break
if v is None or lu < 3:
return None
posto = {(0, 0): seme}
indice = {seme: (0, 0)}
basi = {seme: (u, v)}
coda = deque([seme])
while coda:
a = coda.popleft()
i, j = indice[a]
u, v = basi[a]
tol = 0.3 * min(float(np.hypot(*u)), float(np.hypot(*v)))
for di, dj, vec in ((0, 1, u), (0, -1, -u), (1, 0, v), (-1, 0, -v)):
if (i + di, j + dj) in posto:
continue
prev = pts[a] + vec
dist = np.hypot(pts[:, 0] - prev[0], pts[:, 1] - prev[1])
b = int(np.argmin(dist))
if dist[b] > tol or b in indice:
continue
if not _e_angolo_x(lisc, delta, pts[b], u, v): # niente L/T del bordo
continue
posto[(i + di, j + dj)] = b
indice[b] = (i + di, j + dj)
reale = pts[b] - pts[a]
basi[b] = (reale * dj, v) if dj else (u, reale * di) # base locale aggiornata
coda.append(b)
return posto
def _rettangolo_pieno(posto: dict) -> tuple[int, int, int, int] | None:
"""Il rettangolo di indici tutto occupato di area massima: (i0, j0, righe, colonne)."""
if not posto:
return None
i_min = min(i for i, _ in posto)
j_min = min(j for _, j in posto)
nr = max(i for i, _ in posto) - i_min + 1
nc = max(j for _, j in posto) - j_min + 1
m = np.zeros((nr, nc), bool)
for (i, j) in posto:
m[i - i_min, j - j_min] = True
migliore = (0, 0, 0, 0)
alt = np.zeros(nc, int)
for r in range(nr): # istogramma delle altezze + pila, O(righe*colonne)
alt = np.where(m[r], alt + 1, 0)
pila: list[int] = []
for c in range(nc + 1):
h = int(alt[c]) if c < nc else 0
while pila and alt[pila[-1]] >= h:
altezza = int(alt[pila.pop()])
sx = pila[-1] + 1 if pila else 0
larg = c - sx
if altezza * larg > migliore[2] * migliore[3]:
migliore = (r - altezza + 1 + i_min, sx + j_min, altezza, larg)
pila.append(c)
return migliore if migliore[2] * migliore[3] else None
def _orienta(g: np.ndarray) -> np.ndarray:
"""Riordina (nr,nc,2) con colonne verso destra e righe verso il basso."""
a = (g[:, -1] - g[:, 0]).mean(axis=0) # direzione dell'indice di colonna
b = (g[-1, :] - g[0, :]).mean(axis=0) # direzione dell'indice di riga
if abs(b[0]) > abs(a[0]): # assi scambiati (scacchiera ruotata di ~90 gradi)
g = g.transpose(1, 0, 2)
a, b = b, a
if a[0] < 0:
g = g[:, ::-1]
if b[1] < 0:
g = g[::-1]
return np.ascontiguousarray(g)
def _passo_mediano(g: np.ndarray) -> float:
"""Distanza mediana (px) fra corner adiacenti."""
passi = np.concatenate([np.diff(g, axis=1).reshape(-1, 2), np.diff(g, axis=0).reshape(-1, 2)])
return float(np.median(np.hypot(passi[:, 0], passi[:, 1])))
def _raffina(img: np.ndarray, pts: np.ndarray, passo: float) -> np.ndarray:
"""cornerSubPix su (n,2) con finestra ~0.3 passo, ristretta per chi sta vicino al bordo.
Una finestra che esce dall'immagine vede il bordo replicato e sposta il corner
di ~1 px (visto sull'ultima colonna di una scacchiera a 14 px dal bordo): per
questi punti si usa la finestra piu' grande che sta dentro; sotto 3 px resta
la posizione grezza.
"""
h, w = img.shape
out = pts.astype(np.float32).copy()
win = int(min(20, max(3, 0.3 * passo)))
dentro = np.minimum.reduce([out[:, 0], w - 1 - out[:, 0], out[:, 1], h - 1 - out[:, 1]])
per_punto = np.minimum(win, np.floor(dentro).astype(int) - 2)
crit = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_COUNT, 40, 0.001)
for wi in np.unique(per_punto[per_punto >= 3]):
sel = per_punto == wi
out[sel] = cv2.cornerSubPix(img, out[sel].reshape(-1, 1, 2).copy(),
(int(wi), int(wi)), (-1, -1), crit).reshape(-1, 2)
return out
def _trova_griglia(frame: np.ndarray) -> np.ndarray:
"""Griglia di angoli (n_righe, n_colonne, 2) x,y, in ordine di riga dall'alto."""
h, w = frame.shape
migliore = None
lisc = cv2.GaussianBlur(frame.astype(np.float32), (0, 0), 1.0)
p5, p95 = np.percentile(frame, (5, 95))
delta = 0.25 * float(p95 - p5) # contrasto minimo fra quadranti opposti
for sigma in _SIGME:
pts = _candidati(frame, sigma)
posto = _cresci_reticolo(pts, (w / 2.0, h / 2.0), lisc, delta)
rett = _rettangolo_pieno(posto) if posto else None
if rett is None:
continue
i0, j0, nr, nc = rett
if nr < 3 or nc < 3:
continue
if migliore is not None and nr * nc < 0.5 * migliore[0] * migliore[1]:
break # oltre il picco: le scale piu' grosse fondono gli angoli
if migliore is None or nr * nc > migliore[0] * migliore[1]:
g = np.array([[pts[posto[(i0 + i, j0 + j)]] for j in range(nc)]
for i in range(nr)])
migliore = (nr, nc, g)
if migliore is None:
raise CalibrazioneError(
"griglia della scacchiera non trovata: serve una scacchiera nitida "
"che riempia il campo")
g = _orienta(migliore[2])
nr, nc, _ = g.shape
passo = _passo_mediano(g)
# sub-pixel: finestra proporzionale al passo, abbastanza piccola da non vedere i vicini
grezzi = g.reshape(-1, 2).astype(np.float32)
raff = _raffina(frame, grezzi, passo)
spostati = np.hypot(*(raff - grezzi).T) > 0.3 * passo
raff[spostati] = grezzi[spostati] # un raffinamento che scappa e' peggio del grezzo
return raff.reshape(nr, nc, 2).astype(np.float64)
def _estrapola(g: np.ndarray, k: int, prima: bool) -> np.ndarray:
"""Aggiunge k colonne (asse 1) per estrapolazione lineare, prima o dopo."""
if k <= 0:
return g
if prima:
passo = g[:, 0] - g[:, 1]
extra = np.stack([g[:, 0] + m * passo for m in range(k, 0, -1)], axis=1)
return np.concatenate([extra, g], axis=1)
passo = g[:, -1] - g[:, -2]
extra = np.stack([g[:, -1] + m * passo for m in range(1, k + 1)], axis=1)
return np.concatenate([g, extra], axis=1)
def _estendi_asse(g: np.ndarray, lungh: int, c: int, passo: float,
grid_step: int) -> tuple[np.ndarray, int]:
"""Estende la griglia (asse 1) fino a coprire l'immagine; ritorna (griglia, k_prima).
`c` e' la coordinata (0 = x, 1 = y) e `lungh` la dimensione dell'immagine su
quell'asse. `from_grid` (crop_domain=False) tiene costante la mappa fuori
dalla griglia: senza anelli in piu' i bordi dell'immagine raddrizzata
sarebbero sbavati.
"""
margine = max(0.0, float(g[:, 0, c].min()))
k_pre = int(math.ceil(margine / passo)) + 1
g = _estrapola(g, k_pre, True)
offset = int(g[0, 0, c]) # lo stesso int() che usa from_grid
# colonne totali perche' l'ultimo nodo, a passo grid_step, superi il bordo
servono = int(math.ceil((lungh - 1 - offset) / grid_step)) + 2
g = _estrapola(g, max(0, servono - g.shape[1]), False)
return g, k_pre
class MappaPronta(NamedTuple):
"""Mappa gia' convertita in virgola fissa per un cv2.remap veloce (#84)."""
map1: np.ndarray
map2: np.ndarray
pixel_size: float
shape: tuple # (altezza, larghezza) dei frame per cui vale
def _prepara_mappa(mappa: DistortionMap) -> MappaPronta:
# CV_16SC2: remap bilineare in virgola fissa, piu' veloce di float32 su 5 MP
m1, m2 = cv2.convertMaps(mappa.map_col, mappa.map_row, cv2.CV_16SC2)
return MappaPronta(m1, m2, float(mappa.pixel_size), mappa.shape)
def raddrizza(frame: np.ndarray, mappa: MappaPronta) -> np.ndarray | None:
"""Frame raddrizzato, o None se le dimensioni non sono quelle della calibrazione."""
if frame.shape[:2] != tuple(mappa.shape):
return None
return cv2.remap(frame, mappa.map1, mappa.map2, cv2.INTER_LINEAR,
borderMode=cv2.BORDER_CONSTANT, borderValue=0)
def calibra_scacchiera_densa(pixels, square_mm: float) -> tuple[dict, DistortionMap]:
"""Calibra da UNA foto di scacchiera densa (riempie il campo). Input: solo square_mm.
Ritorna (risultato, mappa). Risultato: {px_per_mm, mm_per_px (dell'immagine
RADDRIZZATA), rms_px, residuo_rms_px, residuo_max_px, n_corners, cols, rows,
square_mm, image_width, image_height, scale_spread_pct (0: la mappa uniforma
la scala), copertura_pct, metodo, distorsione, overlay_png (b64)}.
"""
if not (square_mm > 0 and math.isfinite(square_mm)):
raise CalibrazioneError("square_mm deve essere > 0")
frame = _gray_u8(pixels)
h, w = frame.shape
g = _trova_griglia(frame)
nr, nc, _ = g.shape
passo = _passo_mediano(g)
grid_step = int(round(passo))
if grid_step < 2:
raise CalibrazioneError("calibrazione degenere: passo della griglia troppo piccolo")
# anelli in piu' ai bordi (x e poi y), poi la mappa dai punti estesi
e, kl = _estendi_asse(g, w, 0, passo, grid_step)
et, kt = _estendi_asse(e.transpose(1, 0, 2), h, 1, passo, grid_step)
e = et.transpose(1, 0, 2)
try:
mappa = DistortionMap.from_grid(
e[..., 0].ravel(), e[..., 1].ravel(), e.shape[1], e.shape[0],
image_size=(w, h), grid_step=grid_step, crop_domain=False,
grid_step_measure=square_mm)
except ValueError as exc:
raise CalibrazioneError(f"mappa di distorsione non costruibile: {exc}") from exc
mm_per_px = float(mappa.pixel_size)
if not (mm_per_px > 0 and math.isfinite(mm_per_px)):
raise CalibrazioneError("calibrazione degenere: scala non valida")
# qualita': si raddrizza la stessa foto e si rilegge ogni corner nel punto ideale
off_c, off_r = int(e[0, 0, 0]), int(e[0, 0, 1])
ideali = np.array([[(off_c + (kl + j) * grid_step, off_r + (kt + i) * grid_step)
for j in range(nc)] for i in range(nr)], np.float32).reshape(-1, 1, 2)
rett = cv2.remap(frame, mappa.map_col, mappa.map_row, cv2.INTER_LINEAR,
borderMode=cv2.BORDER_CONSTANT, borderValue=0)
# solo i corner ideali dentro l'immagine raddrizzata (cornerSubPix rifiuta quelli fuori)
xy = ideali.reshape(-1, 2)
dentro = (xy[:, 0] >= 1) & (xy[:, 0] <= w - 2) & (xy[:, 1] >= 1) & (xy[:, 1] <= h - 2)
if int(dentro.sum()) < 4:
raise CalibrazioneError("griglia raddrizzata fuori dall'immagine: calibrazione non verificabile")
ideali = ideali[dentro].reshape(-1, 2)
letti = _raffina(rett, ideali, grid_step)
scarto = (letti - ideali).astype(np.float64)
scarto -= np.median(scarto, axis=0) # int() dell'offset sposta tutto di una traslazione
dist = np.hypot(scarto[:, 0], scarto[:, 1])
residuo_max = float(dist.max())
residuo_rms = float(math.sqrt(float((dist ** 2).mean())))
# copertura: area del quadrilatero dei quattro angoli esterni / area immagine
quad = np.array([g[0, 0], g[0, -1], g[-1, -1], g[-1, 0]], np.float32)
copertura = float(cv2.contourArea(quad)) / float(w * h) * 100.0
# overlay: corner trovati sull'originale (ridotto a max 1600 px: 5 MP in PNG pesano)
f = min(1.0, 1600.0 / w)
bgr = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR)
pts = g.reshape(-1, 1, 2).astype(np.float32)
if f < 1.0:
bgr = cv2.resize(bgr, None, fx=f, fy=f, interpolation=cv2.INTER_AREA)
pts = pts * np.float32(f)
cv2.drawChessboardCorners(bgr, (nc, nr), pts, True)
ok, png = cv2.imencode(".png", bgr)
if not ok:
raise RuntimeError("PNG dell'overlay non codificabile")
return {
"px_per_mm": 1.0 / mm_per_px,
"mm_per_px": mm_per_px,
"rms_px": residuo_rms,
"residuo_rms_px": residuo_rms,
"residuo_max_px": residuo_max,
"n_corners": int(nr * nc),
"cols": int(nc),
"rows": int(nr),
"square_mm": float(square_mm),
"image_width": int(w),
"image_height": int(h),
"scale_spread_pct": 0.0,
"copertura_pct": copertura,
"metodo": "densa",
"distorsione": True,
"overlay_png": base64.b64encode(png.tobytes()).decode("ascii"),
}, mappa
def ora_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
class NomeNonValido(ValueError):
"""#91: nome di calibrazione vuoto, troppo lungo o con caratteri di controllo (400)."""
class NomeGiaUsato(ValueError):
"""#91: esiste gia' una calibrazione con questo nome (senza distinguere maiuscole) (409)."""
class NomeSconosciuto(KeyError):
"""#91: nessuna calibrazione con questo nome (404)."""
def pulisci_nome(nome) -> str:
"""#91: nome valido = strip, 1-60 caratteri, niente caratteri di controllo."""
if not isinstance(nome, str):
raise NomeNonValido("il nome della calibrazione deve essere un testo")
nome = nome.strip()
if not nome:
raise NomeNonValido("il nome della calibrazione è vuoto")
if len(nome) > 60:
raise NomeNonValido("il nome della calibrazione supera i 60 caratteri")
if any(unicodedata.category(c) == "Cc" for c in nome):
raise NomeNonValido("il nome della calibrazione contiene caratteri di controllo")
return nome
# #91: campi che l'elenco espone per ogni calibrazione (il resto resta nel JSON)
_CAMPI_ELENCO = ("nome", "origine", "image_width", "image_height", "px_per_mm",
"mm_per_px", "metodo", "square_mm", "calibrated_at")
class Archivio:
"""#91: archivio UNICO di calibrazioni con nome, in `cartella`.
Libreria: <cartella>/calibrazioni/<nome ripulito>-<sha1 del nome>.json (+ .npz se
densa, stesso nome). Attive: <cartella>/attive.json = {sorgente: nome}. Una
calibrazione non appartiene a una sorgente: la sorgente ne sceglie una (`attiva`).
#84: `leggi_mappa` tiene la mappa in memoria finche' il file non cambia.
Il formato di prima (un JSON per sorgente, campo "source", in <cartella>) si
migra da solo al costruttore (`migra`, idempotente).
"""
def __init__(self, cartella: Path | str):
self.cartella = Path(cartella)
self._cache: dict = {} # percorso .npz -> (mtime_ns, MappaPronta)
self._lock = threading.Lock()
self._lock_dati = threading.RLock() # #91: libreria e attive.json (leggi-modifica-scrivi)
self.migra()
# ---- percorsi
def _file(self, sorgente: str) -> Path:
# caratteri sicuri + hash dell'originale: due sorgenti diverse non si pestano
# (#91: oggi serve ai settaggi camera e al formato vecchio da migrare)
pulito = re.sub(r"[^A-Za-z0-9._-]+", "_", sorgente).strip("._") or "sorgente"
sigla = hashlib.sha1(sorgente.encode("utf-8")).hexdigest()[:8]
return self.cartella / f"{pulito[:60]}-{sigla}.json"
@property
def _libreria(self) -> Path:
return self.cartella / "calibrazioni"
def _file_nome(self, nome: str) -> Path:
# #91: stesso schema di _file, ma l'hash e' sul nome senza maiuscole/minuscole
pulito = re.sub(r"[^A-Za-z0-9._-]+", "_", nome).strip("._") or "calibrazione"
sigla = hashlib.sha1(nome.casefold().encode("utf-8")).hexdigest()[:8]
return self._libreria / f"{pulito[:60]}-{sigla}.json"
def _file_mappa_nome(self, nome: str) -> Path:
return self._file_nome(nome).with_suffix(".npz") # #84 stesso nome del JSON
@property
def _file_attive(self) -> Path:
return self.cartella / "attive.json"
# ---- scrittura atomica
def _scrivi_json(self, destinazione: Path, dati: dict) -> None:
destinazione.parent.mkdir(parents=True, exist_ok=True)
# scrittura atomica: file temporaneo nella stessa cartella, poi replace
fd, tmp = tempfile.mkstemp(dir=destinazione.parent, suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(dati, f, ensure_ascii=False, indent=2)
os.replace(tmp, destinazione)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
def _leggi_json(self, percorso: Path) -> dict | None:
try:
dati = json.loads(percorso.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None # assente o illeggibile
return dati if isinstance(dati, dict) else None
# ---- attive
def _leggi_attive(self) -> dict:
dati = self._leggi_json(self._file_attive) or {}
return {k: v for k, v in dati.items() if isinstance(k, str) and isinstance(v, str)}
def _scrivi_attive(self, attive: dict) -> None:
self._scrivi_json(self._file_attive, attive)
# ---- migrazione dal formato vecchio
def migra(self) -> None:
"""#91: ogni <cartella>/*.json con "source" (una per sorgente) entra in libreria.
Nome = valore di "source" (gia' preso: "<source> (2)", "(3)"...); il .npz
accanto segue; la sorgente resta calibrata con quella voce (attive.json).
Idempotente: il vecchio file sparisce, un secondo giro non trova nulla.
"""
if not self.cartella.is_dir():
return
with self._lock_dati:
for vecchio in sorted(self.cartella.glob("*.json")):
if vecchio.name == self._file_attive.name:
continue
dati = self._leggi_json(vecchio)
sorgente = dati.get("source") if dati else None
if not isinstance(sorgente, str) or not sorgente.strip():
continue
try:
self._migra_uno(vecchio, dati, sorgente)
except OSError:
continue # un file bloccato non ferma il servizio: riprova al prossimo avvio
def _migra_uno(self, vecchio: Path, dati: dict, sorgente: str) -> None:
base = "".join(c for c in sorgente if unicodedata.category(c) != "Cc").strip()[:60].strip() or "calibrazione"
nome, n = base, 1
while True:
esistente = self.leggi_nome(nome)
if esistente is None:
break
if (esistente.get("calibrated_at") == dati.get("calibrated_at")
and esistente.get("px_per_mm") == dati.get("px_per_mm")):
break # gia' copiata da un giro interrotto: si completa e basta
n += 1
suffisso = f" ({n})"
nome = base[:60 - len(suffisso)].rstrip() + suffisso
nuovo = {k: v for k, v in dati.items() if k != "source"}
nuovo["nome"] = nome
nuovo.setdefault("origine", sorgente) # l'origine vera non era registrata
vecchia_mappa = vecchio.with_suffix(".npz")
if vecchia_mappa.is_file(): # prima la mappa, poi il JSON che la dichiara
self._libreria.mkdir(parents=True, exist_ok=True)
os.replace(vecchia_mappa, self._file_mappa_nome(nome))
self._scrivi_json(self._file_nome(nome), nuovo)
attive = self._leggi_attive()
attive[sorgente.strip()] = nome
self._scrivi_attive(attive)
vecchio.unlink()
# ---- libreria
def leggi_nome(self, nome: str) -> dict | None:
"""#91: la calibrazione con questo nome (senza distinguere maiuscole), o None."""
try:
nome = pulisci_nome(nome)
except NomeNonValido:
return None
return self._leggi_json(self._file_nome(nome))
def _meta(self, dati: dict, attive: dict) -> dict:
meta = {k: dati.get(k) for k in _CAMPI_ELENCO}
chiave = str(dati.get("nome", "")).casefold()
meta["attiva_su"] = sorted(s for s, n in attive.items() if n.casefold() == chiave)
return meta
def elenco(self) -> list[dict]:
"""#91: metadati di tutte le calibrazioni, dalla piu' recente."""
with self._lock_dati:
attive = self._leggi_attive()
voci = []
for percorso in self._libreria.glob("*.json") if self._libreria.is_dir() else []:
dati = self._leggi_json(percorso)
if dati and isinstance(dati.get("nome"), str):
voci.append(self._meta(dati, attive))
voci.sort(key=lambda m: str(m.get("calibrated_at") or ""), reverse=True)
return voci
def metadati(self, nome: str) -> dict | None:
"""#91: i metadati di una voce (come in `elenco`), o None."""
dati = self.leggi_nome(nome)
return self._meta(dati, self._leggi_attive()) if dati else None
def salva_nome(self, nome: str, dati: dict, mappa: DistortionMap | None = None,
sovrascrivi: bool = False) -> dict:
"""#91: salva `dati` (e la mappa, se densa) con questo nome; ritorna i metadati."""
nome = pulisci_nome(nome)
destinazione = self._file_nome(nome)
with self._lock_dati:
if destinazione.exists() and not sovrascrivi:
esistente = self._leggi_json(destinazione) or {}
raise NomeGiaUsato(f"esiste già una calibrazione «{esistente.get('nome', nome)}»")
corpo = {k: v for k, v in dati.items() if k not in ("source", "overlay_png", "saved")}
corpo["nome"] = nome
self._libreria.mkdir(parents=True, exist_ok=True)
# #84: prima la mappa (o la rimozione di una vecchia), poi il JSON che la dichiara
if mappa is not None:
self._salva_mappa(nome, mappa)
else:
self._togli_mappa(nome) # voce planare: una vecchia mappa non vale piu'
self._scrivi_json(destinazione, corpo)
return self._meta(corpo, self._leggi_attive())
def _salva_mappa(self, nome: str, mappa: DistortionMap) -> None:
destinazione = self._file_mappa_nome(nome)
fd, tmp = tempfile.mkstemp(dir=destinazione.parent, suffix=".tmp.npz") # .npz: numpy non aggiunge nulla
os.close(fd)
try:
mappa.save(tmp)
os.replace(tmp, destinazione) # atomica come il JSON
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
with self._lock:
self._cache.pop(str(destinazione), None)
def _togli_mappa(self, nome: str) -> None:
destinazione = self._file_mappa_nome(nome)
try:
destinazione.unlink()
except FileNotFoundError:
pass
with self._lock:
self._cache.pop(str(destinazione), None)
def elimina_nome(self, nome: str) -> bool:
"""#91: toglie la voce (JSON e mappa) e la scollega da ogni sorgente. False se non c'era."""
dati = self.leggi_nome(nome)
if dati is None:
return False
canonico = str(dati.get("nome", nome))
with self._lock_dati:
try:
self._file_nome(canonico).unlink()
except FileNotFoundError:
pass
self._togli_mappa(canonico)
attive = self._leggi_attive()
restanti = {s: n for s, n in attive.items() if n.casefold() != canonico.casefold()}
if restanti != attive:
self._scrivi_attive(restanti)
return True
# ---- sorgenti
def attiva(self, sorgente: str, nome: str) -> str:
"""#91: la sorgente usa questa calibrazione; ritorna il nome com'e' in libreria."""
dati = self.leggi_nome(nome)
if dati is None:
raise NomeSconosciuto(nome)
canonico = str(dati["nome"])
with self._lock_dati:
attive = self._leggi_attive()
attive[sorgente] = canonico
self._scrivi_attive(attive)
return canonico
def scollega(self, sorgente: str) -> None:
"""#91: la sorgente non ha piu' una calibrazione attiva; la libreria resta."""
with self._lock_dati:
attive = self._leggi_attive()
if sorgente in attive:
del attive[sorgente]
self._scrivi_attive(attive)
def attiva_di(self, sorgente: str) -> str | None:
"""#91: il nome della calibrazione attiva della sorgente (se esiste ancora)."""
nome = self._leggi_attive().get(sorgente)
dati = self.leggi_nome(nome) if nome else None
return str(dati["nome"]) if dati else None
def leggi(self, sorgente: str) -> dict | None:
"""#91: la calibrazione ATTIVA della sorgente, con "nome" e "source"; None se nessuna."""
nome = self.attiva_di(sorgente)
dati = self.leggi_nome(nome) if nome else None
return {**dati, "source": sorgente} if dati else None
def leggi_mappa(self, sorgente: str) -> MappaPronta | None:
"""La mappa della calibrazione attiva, pronta per `raddrizza`; None se non ne ha una."""
nome = self.attiva_di(sorgente) # #91: la mappa e' della voce attiva, non della sorgente
if nome is None:
return None
percorso = self._file_mappa_nome(nome)
chiave = str(percorso)
try:
mtime = percorso.stat().st_mtime_ns
except OSError:
with self._lock:
self._cache.pop(chiave, None)
return None
with self._lock:
voce = self._cache.get(chiave)
if voce is not None and voce[0] == mtime:
return voce[1]
try:
pronta = _prepara_mappa(DistortionMap.load(percorso))
except (OSError, ValueError, KeyError):
return None # illeggibile = nessuna correzione
with self._lock:
self._cache[chiave] = (mtime, pronta)
return pronta
File diff suppressed because it is too large Load Diff
+391 -24
View File
@@ -4,19 +4,75 @@ Cosa fa: espone via HTTP l'acquisizione di una camera (vs-camera: GigE/GenTL,
OpenCV, cartella di immagini) con lo stesso contratto di `vs-gui`, ma solo
`grab` (copiato da TieVision-Studio, src/visionsuite/gui/server.py):
GET /camera/sources -> {"sources": [{name, kind, label}]}
POST /camera/grab json {source, trigger="continuo", timeout_ms=5000} -> PNG
POST /camera/grab json {source, trigger="continuo", timeout_ms=5000,
grezzo=false} -> PNG
#84: se la sorgente ha una mappa di distorsione salvata il frame esce
raddrizzato (header X-Raddrizzato: si|no); "grezzo": true lo dà com'è.
Errori: 400 parametri, 404 sorgente inesistente, 503 guasto camera, 504 timeout.
Calibrazione a scacchiera (#83, logica in scripts/calibrazione.py).
#91: archivio UNICO di calibrazioni con nome; una sorgente ne ha una attiva:
POST /camera/calibrate multipart: source (obbligatorio solo senza image),
square_mm, image (file, opzionale: senza, grab GREZZO dalla sorgente),
cols/rows (opzionali). NON salva mai (il vecchio campo save e' ignorato).
#84: SENZA cols/rows = calibrazione densa (default): una foto di
scacchiera che riempie il campo, griglia trovata da sola, mappa di
distorsione; mm/px e px/mm sono quelli dell'immagine RADDRIZZATA. CON
cols/rows (angoli interni) = metodo planare di prima. Risposta: i campi
del calcolo + overlay_png (b64), calibrated_at, origine ("camera:<src>" |
"file:<nome>"), calcolo_id (il servizio tiene in memoria gli ultimi 8
calcoli) e metodo ("densa"|"planare"), distorsione (bool) e, per la
densa, residuo_rms_px, residuo_max_px, copertura_pct;
422 scacchiera non trovata o parametri invalidi
GET /camera/calibrazioni[?source=S] -> {calibrazioni: [{nome, origine,
image_width, image_height, px_per_mm, mm_per_px, metodo, square_mm,
calibrated_at, attiva_su}], attiva: nome|null} (dalla piu' recente)
POST /camera/calibrazioni json {calcolo_id, nome, sovrascrivi=false} -> 201
metadati. 404 calcolo scaduto, 400 nome non valido, 409 nome gia' usato
DELETE /camera/calibrazioni?nome=X -> 204 (toglie anche la mappa e la scollega
da ogni sorgente) | 404
PUT /camera/calibrazione-attiva json {source, nome}: legge un frame dalla
sorgente e carica la calibrazione solo se la risoluzione (larghezza x
altezza) coincide: 409 altrimenti; 404 nome ignoto; 200 come GET sotto
GET /camera/calibration?source=<src> -> 200 la calibrazione ATTIVA {source,
nome, px_per_mm, mm_per_px, ..., image_width, image_height,
calibrated_at} | 404 "sorgente non calibrata" (lo legge il backend)
DELETE /camera/calibration?source=<src> -> 204 (SCOLLEGA la calibrazione attiva,
la libreria resta)
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.
Le calibrazioni stanno in <config-dir>/calibrazioni, le attive in attive.json (default
%ProgramData%/Tielogic/TieMeasureFlow/acquisitore su Windows, ./acquisitore altrove).
Serve perche' vs-gui ascolta solo su 127.0.0.1 e il container `client` non lo
raggiunge; questo servizio permette di scegliere l'host di ascolto.
Uso (dalla radice del repo):
vendor/visionsuite/.venv/bin/python scripts/camera_service.py \
[--host 127.0.0.1] [--port 8765] [--folder <cartella immagini>]
[--host 127.0.0.1] [--port 8765] [--folder <cartella immagini>] \
[--config-dir <cartella calibrazioni>]
--folder abilita la sorgente finta `camera:folder` (loop sulle immagini).
Da Docker su Linux: --host 172.17.0.1 (gateway di docker0).
Dipende da: venv di VisionSuite (`uv sync --all-packages` in vendor/visionsuite):
vs-camera, fastapi, uvicorn, opencv.
vs-camera, fastapi, uvicorn, opencv, python-multipart (rotta /camera/calibrate),
visionsuite.calibration (vs-core).
AVVERTENZA: nessuna autenticazione. Un --host diverso dal loopback espone la
camera a chiunque raggiunga la porta: solo su rete isolata.
@@ -24,18 +80,36 @@ camera a chiunque raggiunga la porta: solo su rete isolata.
from __future__ import annotations
import argparse
import logging
import os
import sys
import threading
import uuid # #91 id dei calcoli in attesa di nome
from collections import OrderedDict
from contextlib import asynccontextmanager
from pathlib import Path
import cv2
from fastapi import Body, FastAPI, HTTPException
from fastapi.responses import Response
import numpy as np
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)
sys.path.insert(0, str(Path(__file__).resolve().parent))
import calibrazione # noqa: E402
from calibrazione_pagina import PAGINA # noqa: E402
_TRIGGER = ("continuo",)
_MAX_CALCOLI = 8 # #91: calcoli tenuti in memoria in attesa di un nome
_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 = {
@@ -61,10 +135,23 @@ def _traduci(messaggio: str) -> str:
return messaggio
def create_app(folder: Path | str | None = None) -> FastAPI:
def _config_dir_default() -> Path:
"""#83: cartella delle calibrazioni salvate."""
if os.name == "nt":
base = os.environ.get("ProgramData") or "C:/ProgramData"
return Path(base) / "Tielogic" / "TieMeasureFlow" / "acquisitore"
return Path("acquisitore")
def create_app(folder: Path | str | None = None,
config_dir: Path | str | None = None) -> FastAPI:
archivio = calibrazione.Archivio(config_dir if config_dir is not None
else _config_dir_default()) # #83
aperte: dict = {}
lock = threading.Lock() # protegge SOLO i dizionari, mai un'apertura
lock_sorgenti: dict = {} # nome sorgente -> Lock che serializza apertura e grab
calcoli: OrderedDict = OrderedDict() # #91: calcolo_id -> (dati, mappa); gli ultimi _MAX_CALCOLI
lock_calcoli = threading.Lock()
def _config() -> dict:
if folder is None:
@@ -95,6 +182,52 @@ def create_app(folder: Path | str | None = None) -> FastAPI:
return {"sources": [{"name": v.name, "kind": v.kind, "label": v.label}
for v in voci if v.kind in ("camera", "virtual")]}
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())
# TieVision-Core#341 / #89: free-run, serve la scena di adesso, non il frame vecchio in coda
if hasattr(sorgente, "solo_ultimo_frame"):
try:
sorgente.solo_ultimo_frame()
except Exception as e: # noqa: BLE001 - producer senza il nodo: si apre lo stesso
_log.warning("frame piu' recente non impostabile su %s: %s", nome, e)
_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
return fn(_apri(nome))
except SourceNotFound as e:
raise HTTPException(404, _traduci(str(e)))
except TimeoutError as e:
raise HTTPException(504, _traduci(str(e)) or "timeout")
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
@@ -104,28 +237,260 @@ def create_app(folder: Path | str | None = None) -> FastAPI:
trigger = corpo.get("trigger", "continuo")
if trigger not in _TRIGGER:
raise HTTPException(400, "«trigger» supportato: solo continuo")
grezzo = corpo.get("grezzo", False) # #84: true = frame non raddrizzato
if not isinstance(grezzo, bool):
raise HTTPException(400, "«grezzo» deve essere true o false")
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")
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
frame = sorgente.grab(timeout)
except SourceNotFound as e:
raise HTTPException(404, _traduci(str(e)))
except TimeoutError as e:
raise HTTPException(504, _traduci(str(e)) or "timeout")
except (CameraGrabError, CameraError) as e:
raise HTTPException(503, _traduci(str(e)))
frame = _grab(nome, timeout) # #83: corpo estratto in _grab, invariato
raddrizzato = False
if not grezzo: # #84: sorgente calibrata (densa) -> frame raddrizzato dalla mappa in cache (#91: quella attiva)
mappa = archivio.leggi_mappa(nome)
if mappa is not None:
corretto = calibrazione.raddrizza(frame, mappa)
if corretto is not None: # None = dimensioni diverse da quelle calibrate
frame, raddrizzato = corretto, True
ok, buf = cv2.imencode(".png", frame)
if not ok:
raise HTTPException(500, "codifica PNG fallita")
return Response(buf.tobytes(), media_type="image/png")
return Response(buf.tobytes(), media_type="image/png",
headers={"X-Raddrizzato": "si" if raddrizzato else "no"})
# ---- #83: calibrazione a scacchiera per sorgente
def _sorgente(valore) -> str:
if not isinstance(valore, str) or not valore.strip():
raise HTTPException(400, "manca «source»")
return valore.strip()
@app.get("/camera/calibration")
def calibrazione_leggi(source: str = ""):
dati = archivio.leggi(_sorgente(source)) # #91: la calibrazione ATTIVA della sorgente
if dati is None:
raise HTTPException(404, "sorgente non calibrata")
return dati
@app.delete("/camera/calibration", status_code=204)
def calibrazione_elimina(source: str = ""):
archivio.scollega(_sorgente(source)) # #91: scollega soltanto, la libreria resta
return Response(status_code=204)
@app.post("/camera/calibrate")
async def calibrazione_calcola(
source: str | None = Form(None), # #91: serve solo se manca l'immagine
square_mm: float = Form(...),
cols: int | None = Form(None), # #84: opzionali, senza = calibrazione densa
rows: int | None = Form(None),
image: UploadFile | None = File(None),
save: bool = Form(False), # #91: ignorato, il calcolo non salva mai (si salva con nome)
):
contenuto = await image.read() if image is not None else b""
if contenuto:
frame = cv2.imdecode(np.frombuffer(contenuto, np.uint8), cv2.IMREAD_UNCHANGED)
if frame is None:
raise HTTPException(422, "l'immagine caricata non è leggibile")
origine = "file:" + (Path((image.filename or "").replace("\\", "/")).name or "immagine")
else:
nome = _sorgente(source)
frame = await run_in_threadpool(_grab, nome, 5000) # come /camera/grab (404/503/504)
origine = nome
if (cols is None) != (rows is None):
raise HTTPException(422, "cols e rows vanno indicati insieme (o nessuno dei due)")
mappa = None
try:
if cols is None: # #84: densa, nessuna griglia da dichiarare
risultato, mappa = await run_in_threadpool(
calibrazione.calibra_scacchiera_densa, frame, square_mm)
else: # metodo planare di #83, invariato
risultato = calibrazione.calibra_scacchiera(frame, cols, rows, square_mm)
except calibrazione.CalibrazioneError as e:
raise HTTPException(422, str(e))
corpo = {**{k: v for k, v in risultato.items() if k != "overlay_png"},
"calibrated_at": calibrazione.ora_iso(), "origine": origine}
calcolo_id = uuid.uuid4().hex
with lock_calcoli: # #91: il calcolo resta in memoria finche' non gli si da' un nome
calcoli[calcolo_id] = (corpo, mappa)
while len(calcoli) > _MAX_CALCOLI:
calcoli.popitem(last=False)
return {**corpo, "calcolo_id": calcolo_id, "overlay_png": risultato["overlay_png"]}
# ---- #91: archivio unico di calibrazioni con nome
@app.get("/camera/calibrazioni")
def calibrazioni_elenco(source: str | None = None):
attiva = archivio.attiva_di(_sorgente(source)) if source is not None else None
return {"calibrazioni": archivio.elenco(), "attiva": attiva}
@app.post("/camera/calibrazioni", status_code=201)
def calibrazioni_salva(corpo: dict = Body(default=None)):
corpo = corpo if isinstance(corpo, dict) else {}
calcolo_id = corpo.get("calcolo_id")
if not isinstance(calcolo_id, str) or not calcolo_id:
raise HTTPException(400, "manca «calcolo_id»")
sovrascrivi = corpo.get("sovrascrivi", False)
if not isinstance(sovrascrivi, bool):
raise HTTPException(400, "«sovrascrivi» deve essere true o false")
try:
nome = calibrazione.pulisci_nome(corpo.get("nome"))
except calibrazione.NomeNonValido as e:
raise HTTPException(400, str(e))
with lock_calcoli:
voce = calcoli.get(calcolo_id)
if voce is None:
raise HTTPException(404, "calcolo non più disponibile: ricalcolare")
dati, mappa = voce
try:
return archivio.salva_nome(nome, dati, mappa, sovrascrivi)
except calibrazione.NomeGiaUsato as e:
raise HTTPException(409, str(e))
@app.delete("/camera/calibrazioni", status_code=204)
def calibrazioni_elimina(nome: str = ""):
if not archivio.elimina_nome(nome):
raise HTTPException(404, "calibrazione non trovata")
return Response(status_code=204)
@app.put("/camera/calibrazione-attiva")
def calibrazione_attiva(corpo: dict = Body(default=None)):
corpo = corpo if isinstance(corpo, dict) else {}
sorgente = _sorgente(corpo.get("source"))
richiesto = corpo.get("nome")
dati = archivio.leggi_nome(richiesto) if isinstance(richiesto, str) else None
if dati is None:
raise HTTPException(404, "calibrazione non trovata")
nome = dati["nome"]
frame = _grab(sorgente, 5000) # stesso lock e stessi errori di /camera/grab (404/503/504)
alt, larg = frame.shape[:2]
cw, ch = dati.get("image_width"), dati.get("image_height")
if not isinstance(cw, int) or not isinstance(ch, int):
raise HTTPException(409, f"la calibrazione «{nome}» non ha la risoluzione registrata: "
"non si può caricare")
if (cw, ch) != (larg, alt): # la mappa vale solo per frame della stessa risoluzione
raise HTTPException(409, f"la calibrazione «{nome}» è per immagini {cw}×{ch}, "
f"la sorgente dà {larg}×{alt}: non si può caricare")
archivio.attiva(sorgente, nome)
return archivio.leggi(sorgente)
# ---- #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)
return app
@@ -136,8 +501,10 @@ def main() -> None:
p.add_argument("--host", default="127.0.0.1")
p.add_argument("--port", type=int, default=8765)
p.add_argument("--folder", default=None, help="cartella immagini per camera:folder")
p.add_argument("--config-dir", default=None,
help="cartella delle calibrazioni salvate (default: ProgramData su Windows)") # #83
a = p.parse_args()
uvicorn.run(create_app(a.folder), host=a.host, port=a.port)
uvicorn.run(create_app(a.folder, a.config_dir), host=a.host, port=a.port)
if __name__ == "__main__":
+458
View File
@@ -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())
+422
View File
@@ -0,0 +1,422 @@
"""Test della calibrazione dell'acquisitore (#83): logica pura e rotte di camera_service.
Uso: dalla radice, `uv run --python 3.13 --extra vision-worker --extra dev
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q`
(o il venv di VisionSuite come per test_camera_service.py).
"""
import base64
import importlib.util
import json
from pathlib import Path
import cv2
import numpy as np
import pytest
from fastapi.testclient import TestClient
SCRIPTS = Path(__file__).resolve().parents[1]
MODULO = SCRIPTS / "camera_service.py"
def _carica():
spec = importlib.util.spec_from_file_location("camera_service", MODULO)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def una_scacchiera(square_px: int = 40, nx: int = 10, ny: int = 7, margin: int = 60) -> np.ndarray:
"""nx x ny quadrati => (nx-1) x (ny-1) angoli interni, su fondo bianco."""
img = np.full((ny * square_px + 2 * margin, nx * square_px + 2 * margin), 255, np.uint8)
for j in range(ny):
for i in range(nx):
if (i + j) % 2 == 0:
y, x = margin + j * square_px, margin + i * square_px
img[y:y + square_px, x:x + square_px] = 0
return img
def una_scacchiera_densa(w: int = 640, h: int = 480, square_px: int = 40, k: float = 1e-8) -> np.ndarray:
"""#84: scacchiera che riempie tutto il campo, con distorsione a barilotto (k sui px dal centro)."""
m = 3 * square_px # la scacchiera continua oltre il campo: nessun bordo finto nell'immagine
yy, xx = np.mgrid[0:h + 2 * m, 0:w + 2 * m]
piana = (((xx // square_px) + (yy // square_px)) % 2 * 255).astype(np.uint8)
cx, cy = w / 2.0, h / 2.0
dx, dy = (xx[m:m + h, m:m + w] - m - cx).astype(np.float32), (yy[m:m + h, m:m + w] - m - cy).astype(np.float32)
f = 1 + k * (dx * dx + dy * dy)
img = cv2.remap(piana, cx + m + dx * f, cy + m + dy * f, cv2.INTER_LINEAR)
return cv2.GaussianBlur(img, (3, 3), 0.8) # bordi non perfettamente netti, come in camera
def residuo_reticolo(griglia: np.ndarray) -> float:
"""Residuo massimo (px) della griglia (nr,nc,2) da un reticolo affine regolare."""
nr, nc, _ = griglia.shape
ii, jj = np.mgrid[0:nr, 0:nc]
A = np.stack([np.ones(nr * nc), ii.ravel(), jj.ravel()], axis=1)
P = griglia.reshape(-1, 2)
coef, *_ = np.linalg.lstsq(A, P, rcond=None)
return float(np.hypot(*(A @ coef - P).T).max())
def _png(img: np.ndarray) -> bytes:
ok, buf = cv2.imencode(".png", img)
assert ok
return buf.tobytes()
@pytest.fixture
def cartella(tmp_path):
# la sorgente camera:folder fa il loop su una scacchiera: il grab la trova
cartella = tmp_path / "immagini"
cartella.mkdir()
cv2.imwrite(str(cartella / "scacchiera.png"), una_scacchiera())
return cartella
@pytest.fixture
def client(cartella, tmp_path):
mod = _carica()
with TestClient(mod.create_app(cartella, config_dir=tmp_path / "config")) as c:
c.config = tmp_path / "config"
yield c
CAMPI = {"cols": "9", "rows": "6", "square_mm": "2"}
CHIAVI_C1 = {"px_per_mm", "mm_per_px", "rms_px", "n_corners", "cols", "rows",
"square_mm", "image_width", "image_height", "scale_spread_pct", "calibrated_at"}
# #84: campi in piu' per metodo planare (C2 con cols/rows) e per la densa
CHIAVI_PLANARE = CHIAVI_C1 | {"metodo", "distorsione", "origine"} # #91: + origine ("camera:X" | "file:Y")
CHIAVI_DENSA = CHIAVI_PLANARE | {"residuo_rms_px", "residuo_max_px", "copertura_pct"}
SORGENTE = "camera:folder"
# #91: GET /camera/calibration = voce di libreria + "nome" + "source" (la sorgente che la usa)
CHIAVI_ATTIVA = {"source", "nome"}
def salva_con_nome(client, calcolo: dict, nome: str, **extra):
"""#91: POST /camera/calibrazioni sul calcolo_id di una risposta di /camera/calibrate."""
return client.post("/camera/calibrazioni", json={"calcolo_id": calcolo["calcolo_id"], "nome": nome, **extra})
def carica_su(client, sorgente: str, nome: str):
"""#91: PUT /camera/calibrazione-attiva."""
return client.put("/camera/calibrazione-attiva", json={"source": sorgente, "nome": nome})
@pytest.fixture
def cartella_densa(tmp_path):
# #84: la sorgente camera:folder fa il loop su una scacchiera densa distorta
cartella = tmp_path / "immagini_dense"
cartella.mkdir()
cv2.imwrite(str(cartella / "densa.png"), una_scacchiera_densa(k=1e-7))
return cartella
@pytest.fixture
def client_denso(cartella_densa, tmp_path):
mod = _carica()
with TestClient(mod.create_app(cartella_densa, config_dir=tmp_path / "config_densa")) as c:
c.config = tmp_path / "config_densa"
yield c
NON_IMMAGINE = b"questo non e una immagine"
# ---- logica pura
def test_scacchiera_sintetica_da_la_scala():
_carica() # mette scripts/ in sys.path
import calibrazione
r = calibrazione.calibra_scacchiera(una_scacchiera(), 9, 6, 2.0)
assert r["px_per_mm"] == pytest.approx(20.0, rel=0.005)
assert r["mm_per_px"] == pytest.approx(0.05, rel=0.005)
assert r["rms_px"] < 0.5
assert r["n_corners"] == 54
assert (r["image_width"], r["image_height"]) == (520, 400)
assert r["scale_spread_pct"] < 1.0
assert base64.b64decode(r["overlay_png"])[:8] == b"\x89PNG\r\n\x1a\n"
def test_immagine_bianca_solleva():
_carica()
import calibrazione
with pytest.raises(calibrazione.CalibrazioneError, match="non trovata"):
calibrazione.calibra_scacchiera(np.full((300, 300), 255, np.uint8), 9, 6, 2.0)
@pytest.mark.parametrize("cols,rows,quadrato", [(1, 6, 2.0), (9, 6, 0.0), (9, 6, -1.0)])
def test_parametri_invalidi_sollevano(cols, rows, quadrato):
_carica()
import calibrazione
with pytest.raises(calibrazione.CalibrazioneError):
calibrazione.calibra_scacchiera(una_scacchiera(), cols, rows, quadrato)
# ---- rotte
def test_c1_non_calibrata_404(client):
r = client.get("/camera/calibration", params={"source": "camera:folder"})
assert r.status_code == 404
assert r.json()["detail"] == "sorgente non calibrata"
def test_c1_senza_source_400(client):
assert client.get("/camera/calibration").status_code == 400
def test_c2_immagine_caricata_senza_salvare(client):
r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI},
files={"image": ("s.png", _png(una_scacchiera()), "image/png")})
assert r.status_code == 200, r.text
corpo = r.json()
assert CHIAVI_PLANARE | {"overlay_png", "calcolo_id"} == set(corpo) # #91: niente "saved", niente "source"
assert corpo["metodo"] == "planare" and corpo["distorsione"] is False # #84: con cols/rows nulla cambia
assert corpo["origine"] == "file:s.png"
assert corpo["px_per_mm"] == pytest.approx(20.0, rel=0.005)
# il calcolo non salva mai
assert client.get("/camera/calibration", params={"source": "camera:folder"}).status_code == 404
assert not client.config.exists() or not list(client.config.iterdir())
def test_c2_save_true_viene_ignorato(client):
# #91: il vecchio campo save non salva piu' nulla
r = client.post("/camera/calibrate", data={"source": "camera:folder", "save": "true", **CAMPI})
assert r.status_code == 200, r.text
assert client.get("/camera/calibration", params={"source": "camera:folder"}).status_code == 404
assert client.get("/camera/calibrazioni").json()["calibrazioni"] == []
assert not client.config.exists() or not list(client.config.iterdir())
def test_c2_c1_c3_ciclo_con_grab_dalla_sorgente(client):
# nessun file: grab da camera:folder (la scacchiera in cartella)
r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI})
assert r.status_code == 200, r.text
assert r.json()["origine"] == "camera:folder"
assert salva_con_nome(client, r.json(), "Banco").status_code == 201 # #91: si salva con nome, dopo
# salvata ma non ancora caricata sulla sorgente
assert client.get("/camera/calibration", params={"source": "camera:folder"}).status_code == 404
assert carica_su(client, "camera:folder", "Banco").status_code == 200
r1 = client.get("/camera/calibration", params={"source": "camera:folder"})
assert r1.status_code == 200
assert set(r1.json()) == CHIAVI_PLANARE | CHIAVI_ATTIVA # nessun overlay nel file salvato
assert r1.json()["px_per_mm"] == pytest.approx(20.0, rel=0.005)
assert r1.json()["source"] == "camera:folder" and r1.json()["nome"] == "Banco"
file_salvati = list((client.config / "calibrazioni").glob("*.json"))
assert len(file_salvati) == 1
assert json.loads(file_salvati[0].read_text(encoding="utf-8"))["cols"] == 9
# DELETE /camera/calibration scollega: la libreria resta
assert client.delete("/camera/calibration", params={"source": "camera:folder"}).status_code == 204
assert client.get("/camera/calibration", params={"source": "camera:folder"}).status_code == 404
assert len(list((client.config / "calibrazioni").glob("*.json"))) == 1
# scollegare di nuovo e' idempotente
assert client.delete("/camera/calibration", params={"source": "camera:folder"}).status_code == 204
def test_c2_scacchiera_non_trovata_422(client):
bianca = np.full((300, 300), 255, np.uint8)
r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI},
files={"image": ("b.png", _png(bianca), "image/png")})
assert r.status_code == 422
assert "non trovata" in r.json()["detail"]
def test_c2_parametri_invalidi_422(client):
r = client.post("/camera/calibrate",
data={"source": "camera:folder", "cols": "1", "rows": "6", "square_mm": "2"},
files={"image": ("s.png", _png(una_scacchiera()), "image/png")})
assert r.status_code == 422
def test_c2_immagine_illeggibile_422(client):
r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI},
files={"image": ("x.png", NON_IMMAGINE, "image/png")})
assert r.status_code == 422
def test_c2_errori_camera_come_grab(client):
r = client.post("/camera/calibrate", data={"source": "camera:non-esiste", **CAMPI})
assert r.status_code == 404
def test_due_nomi_simili_hanno_file_diversi(client):
# #91: i nomi si ripuliscono come prima (hash sul nome originale): due nomi diversi non si pestano
r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI},
files={"image": ("s.png", _png(una_scacchiera()), "image/png")})
assert r.status_code == 200
for nome in ("banco a b", "banco a_b"):
assert salva_con_nome(client, r.json(), nome).status_code == 201
assert len(list((client.config / "calibrazioni").glob("*.json"))) == 2
def test_c4_pagina_html(client):
r = client.get("/calibrazione")
assert r.status_code == 200
assert r.headers["content-type"].startswith("text/html")
assert "Acquisisci e calibra" in r.text
assert "Lato dello scacco" in r.text and 'id="cols"' not in r.text # #84: un solo campo
assert "http://" not in r.text and "https://" not in r.text # offline, niente CDN
# ---- #84: calibrazione densa (logica pura)
def test_densa_trova_la_griglia_da_sola_e_la_scala():
_carica()
import calibrazione
r, mappa = calibrazione.calibra_scacchiera_densa(una_scacchiera_densa(k=1e-8), 2.0)
assert (r["cols"], r["rows"]) == (15, 11) # 640x480 con quadrati da 40 px
assert r["n_corners"] == 15 * 11
assert r["mm_per_px"] == pytest.approx(2.0 / 40, rel=0.005)
assert r["px_per_mm"] == pytest.approx(20.0, rel=0.005)
assert r["metodo"] == "densa" and r["distorsione"] is True
assert r["residuo_max_px"] < 0.3 and r["residuo_rms_px"] <= r["residuo_max_px"]
assert 60 < r["copertura_pct"] <= 100
assert (r["image_width"], r["image_height"]) == (640, 480)
assert base64.b64decode(r["overlay_png"])[:8] == b"\x89PNG\r\n\x1a\n"
assert mappa.shape == (480, 640) and mappa.pixel_size == pytest.approx(0.05, rel=0.005)
def test_densa_raddrizza_su_griglia_regolare():
_carica()
import calibrazione
img = una_scacchiera_densa(k=1e-7) # distorsione marcata: ai bordi ~5 %
r, mappa = calibrazione.calibra_scacchiera_densa(img, 2.0)
assert (r["cols"], r["rows"]) == (15, 11)
# prima la griglia e' curva, dopo e' un reticolo regolare
assert residuo_reticolo(calibrazione._trova_griglia(img)) > 1.0
pronta = calibrazione._prepara_mappa(mappa)
dritta = calibrazione.raddrizza(img, pronta)
assert dritta.shape == img.shape
assert residuo_reticolo(calibrazione._trova_griglia(dritta)) < 0.3
@pytest.mark.parametrize("cosa", ["bianca", "scacchiera_piccola"])
def test_densa_senza_griglia_solleva(cosa):
_carica()
import calibrazione
img = (np.full((300, 300), 255, np.uint8) if cosa == "bianca"
else una_scacchiera_densa(80, 60, 20)) # 3x2 angoli: meno di 3x3
with pytest.raises(calibrazione.CalibrazioneError, match="non trovata"):
calibrazione.calibra_scacchiera_densa(img, 2.0)
@pytest.mark.parametrize("quadrato", [0.0, -1.0, float("nan")])
def test_densa_quadrato_invalido_solleva(quadrato):
_carica()
import calibrazione
with pytest.raises(calibrazione.CalibrazioneError):
calibrazione.calibra_scacchiera_densa(una_scacchiera_densa(), quadrato)
def test_archivio_mappa_cache_e_invalidazione(tmp_path):
_carica()
import calibrazione
_, mappa = calibrazione.calibra_scacchiera_densa(una_scacchiera_densa(), 2.0)
arch = calibrazione.Archivio(tmp_path)
assert arch.leggi_mappa("s") is None
arch.salva_nome("cal", {"a": 1}, mappa) # #91: salvataggio con nome + attivazione sulla sorgente
arch.attiva("s", "cal")
prima = arch.leggi_mappa("s")
assert prima is not None and arch.leggi_mappa("s") is prima # cache in memoria
assert not list(tmp_path.rglob("*.tmp*")) # niente temporanei rimasti
arch.salva_nome("cal", {"a": 2}, mappa, sovrascrivi=True) # file riscritto -> cache invalidata
assert arch.leggi_mappa("s") is not prima
arch.salva_nome("cal", {"a": 3}, sovrascrivi=True) # salvataggio senza mappa (planare): la vecchia sparisce
assert arch.leggi_mappa("s") is None and not list(tmp_path.rglob("*.npz"))
# ---- #84: rotte con la calibrazione densa
def _grigio(r) -> np.ndarray:
return cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_GRAYSCALE)
def test_c2_senza_cols_rows_e_densa(client_denso):
r = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"})
assert r.status_code == 200, r.text
corpo = r.json()
assert CHIAVI_DENSA | {"overlay_png", "calcolo_id"} == set(corpo)
assert corpo["metodo"] == "densa" and corpo["distorsione"] is True
assert (corpo["cols"], corpo["rows"]) == (15, 11)
assert corpo["px_per_mm"] == pytest.approx(1.0 / corpo["mm_per_px"])
assert not client_denso.config.exists() or not list(client_denso.config.iterdir())
def test_c2_solo_cols_o_solo_rows_422(client_denso):
r = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2", "cols": "9"})
assert r.status_code == 422
def test_grab_raddrizzato_se_calibrata_e_grezzo_a_richiesta(client_denso, cartella_densa):
originale = cv2.imread(str(cartella_densa / "densa.png"), cv2.IMREAD_GRAYSCALE)
prima = client_denso.post("/camera/grab", json={"source": SORGENTE})
assert prima.headers["x-raddrizzato"] == "no" # non calibrata: frame com'e'
assert np.array_equal(_grigio(prima), originale)
r = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"})
assert r.status_code == 200, r.text
assert salva_con_nome(client_denso, r.json(), "Densa").status_code == 201 # #91: salvata con nome
libreria = client_denso.config / "calibrazioni"
json_salvati, mappe = list(libreria.glob("*.json")), list(libreria.glob("*.npz"))
assert len(json_salvati) == 1 and len(mappe) == 1
assert mappe[0].stem == json_salvati[0].stem # stesso nome del JSON
assert client_denso.post("/camera/grab", json={"source": SORGENTE}).headers["x-raddrizzato"] == "no" # salvata != attiva
assert carica_su(client_denso, SORGENTE, "Densa").status_code == 200
saved = client_denso.get("/camera/calibration", params={"source": SORGENTE}).json()
assert set(saved) == CHIAVI_DENSA | CHIAVI_ATTIVA and saved["metodo"] == "densa"
dopo = client_denso.post("/camera/grab", json={"source": SORGENTE})
assert dopo.status_code == 200 and dopo.headers["x-raddrizzato"] == "si"
import calibrazione
dritta = _grigio(dopo)
assert dritta.shape == originale.shape
assert not np.array_equal(dritta, originale)
assert residuo_reticolo(calibrazione._trova_griglia(dritta)) < 0.3 # griglia regolare
grezzo = client_denso.post("/camera/grab", json={"source": SORGENTE, "grezzo": True})
assert grezzo.headers["x-raddrizzato"] == "no"
assert np.array_equal(_grigio(grezzo), originale)
# la calibrazione parte sempre dal frame grezzo: ricalibrare dalla sorgente da' lo stesso mm/px
di_nuovo = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"})
assert di_nuovo.json()["mm_per_px"] == pytest.approx(r.json()["mm_per_px"])
assert di_nuovo.json()["residuo_max_px"] < 0.3
def test_grab_grezzo_non_booleano_400(client_denso):
r = client_denso.post("/camera/grab", json={"source": SORGENTE, "grezzo": "si"})
assert r.status_code == 400
def test_scollega_lascia_la_libreria_ed_elimina_toglie_la_mappa(client_denso):
r = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"})
salva_con_nome(client_denso, r.json(), "Densa")
carica_su(client_denso, SORGENTE, "Densa")
libreria = client_denso.config / "calibrazioni"
assert list(libreria.glob("*.npz"))
# scollegare: il frame torna com'e', ma la voce (e la mappa) restano in libreria
assert client_denso.delete("/camera/calibration", params={"source": SORGENTE}).status_code == 204
assert client_denso.post("/camera/grab", json={"source": SORGENTE}).headers["x-raddrizzato"] == "no"
assert list(libreria.glob("*.npz")) and list(libreria.glob("*.json"))
# eliminare la voce toglie JSON e mappa
carica_su(client_denso, SORGENTE, "Densa")
assert client_denso.delete("/camera/calibrazioni", params={"nome": "Densa"}).status_code == 204
assert not list(libreria.glob("*.npz")) and not list(libreria.glob("*.json"))
assert client_denso.post("/camera/grab", json={"source": SORGENTE}).headers["x-raddrizzato"] == "no"
def test_ricalibrare_planare_toglie_la_mappa_densa(client_denso):
densa = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"})
salva_con_nome(client_denso, densa.json(), "Cal")
carica_su(client_denso, SORGENTE, "Cal")
libreria = client_denso.config / "calibrazioni"
assert list(libreria.glob("*.npz"))
planare = client_denso.post("/camera/calibrate", data={"source": SORGENTE, **CAMPI},
files={"image": ("s.png", _png(una_scacchiera()), "image/png")})
assert planare.status_code == 200 and planare.json()["metodo"] == "planare"
assert salva_con_nome(client_denso, planare.json(), "Cal").status_code == 409 # nome gia' preso
assert salva_con_nome(client_denso, planare.json(), "Cal", sovrascrivi=True).status_code == 201
assert not list(libreria.glob("*.npz")) # niente mappa vecchia che raddrizza a vuoto
assert client_denso.post("/camera/grab", json={"source": SORGENTE}).headers["x-raddrizzato"] == "no"
+233
View File
@@ -0,0 +1,233 @@
"""Test dell'archivio unico di calibrazioni con nome (#91): scripts/calibrazione.py, classe Archivio.
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
Solo tmp_path: niente ProgramData, niente camera.
"""
import json
import pytest
from test_calibrazione import _carica, una_scacchiera_densa
@pytest.fixture(scope="module")
def mappa_densa():
# #91: una mappa vera, calcolata una volta sola per tutti i test del modulo
_carica()
import calibrazione
return calibrazione.calibra_scacchiera_densa(una_scacchiera_densa(), 2.0)[1]
def _dati(**extra):
base = {"px_per_mm": 20.0, "mm_per_px": 0.05, "image_width": 640, "image_height": 480,
"metodo": "planare", "square_mm": 2.0, "calibrated_at": "2026-10-24T10:00:00+00:00",
"origine": "file:s.png"}
return {**base, **extra}
@pytest.fixture
def cal():
_carica()
import calibrazione
return calibrazione
# ---- nomi
@pytest.mark.parametrize("nome", ["", " ", "x" * 61, "a\x00b", "a\nb", "a\tb", None, 5])
def test_nome_non_valido(cal, tmp_path, nome):
with pytest.raises(cal.NomeNonValido):
cal.Archivio(tmp_path).salva_nome(nome, _dati())
def test_nome_valido_limiti_e_strip(cal, tmp_path):
arch = cal.Archivio(tmp_path)
arch.salva_nome("x" * 60, _dati())
meta = arch.salva_nome(" Banco 1 ", _dati())
assert meta["nome"] == "Banco 1" # strip
assert arch.leggi_nome("Banco 1")["nome"] == "Banco 1"
def test_nome_unico_senza_maiuscole(cal, tmp_path):
arch = cal.Archivio(tmp_path)
arch.salva_nome("Banco", _dati(px_per_mm=1.0))
with pytest.raises(cal.NomeGiaUsato, match="Banco"):
arch.salva_nome("BANCO", _dati(px_per_mm=2.0))
assert arch.leggi_nome("banco")["px_per_mm"] == 1.0 # non toccata
assert len(arch.elenco()) == 1
def test_sovrascrivi_sostituisce_e_prende_il_nuovo_nome(cal, tmp_path):
arch = cal.Archivio(tmp_path)
arch.salva_nome("Banco", _dati(px_per_mm=1.0))
arch.salva_nome("BANCO", _dati(px_per_mm=2.0), sovrascrivi=True)
voci = arch.elenco()
assert len(voci) == 1 and voci[0]["nome"] == "BANCO" and voci[0]["px_per_mm"] == 2.0
assert len(list((tmp_path / "calibrazioni").glob("*.json"))) == 1
def test_salva_non_scrive_source_ne_overlay(cal, tmp_path):
arch = cal.Archivio(tmp_path)
arch.salva_nome("A", _dati(source="camera:x", overlay_png="abc", saved=True))
scritto = json.loads(next((tmp_path / "calibrazioni").glob("*.json")).read_text(encoding="utf-8"))
assert "source" not in scritto and "overlay_png" not in scritto and "saved" not in scritto
assert scritto["nome"] == "A" and scritto["origine"] == "file:s.png"
def test_leggi_nome_inesistente_o_non_valido(cal, tmp_path):
arch = cal.Archivio(tmp_path)
assert arch.leggi_nome("nessuna") is None and arch.leggi_nome("") is None
# ---- elenco e attive
def test_elenco_dalla_piu_recente_e_attiva_su(cal, tmp_path):
arch = cal.Archivio(tmp_path)
arch.salva_nome("vecchia", _dati(calibrated_at="2026-01-01T00:00:00+00:00"))
arch.salva_nome("nuova", _dati(calibrated_at="2026-10-01T00:00:00+00:00"))
arch.attiva("camera:b", "nuova")
arch.attiva("camera:a", "nuova")
voci = arch.elenco()
assert [v["nome"] for v in voci] == ["nuova", "vecchia"]
assert voci[0]["attiva_su"] == ["camera:a", "camera:b"] and voci[1]["attiva_su"] == []
assert set(voci[0]) == {"nome", "origine", "image_width", "image_height", "px_per_mm",
"mm_per_px", "metodo", "square_mm", "calibrated_at", "attiva_su"}
def test_attiva_leggi_scollega(cal, tmp_path):
arch = cal.Archivio(tmp_path)
arch.salva_nome("Banco", _dati())
assert arch.attiva_di("camera:a") is None and arch.leggi("camera:a") is None
assert arch.attiva("camera:a", "banco") == "Banco" # nome come in libreria
assert arch.attiva_di("camera:a") == "Banco"
letta = arch.leggi("camera:a")
assert letta["nome"] == "Banco" and letta["source"] == "camera:a" and letta["px_per_mm"] == 20.0
assert json.loads((tmp_path / "attive.json").read_text(encoding="utf-8")) == {"camera:a": "Banco"}
arch.scollega("camera:a")
assert arch.attiva_di("camera:a") is None and arch.leggi("camera:a") is None
assert arch.leggi_nome("Banco") is not None # la libreria resta
arch.scollega("camera:a") # idempotente
def test_attiva_nome_sconosciuto(cal, tmp_path):
with pytest.raises(cal.NomeSconosciuto):
cal.Archivio(tmp_path).attiva("camera:a", "nessuna")
def test_due_sorgenti_stessa_calibrazione(cal, tmp_path):
arch = cal.Archivio(tmp_path)
arch.salva_nome("Comune", _dati())
arch.attiva("camera:a", "Comune")
arch.attiva("camera:b", "Comune")
assert arch.leggi("camera:a")["source"] == "camera:a" and arch.leggi("camera:b")["source"] == "camera:b"
def test_elimina_nome_toglie_da_attive_e_mappa(cal, tmp_path, mappa_densa):
arch = cal.Archivio(tmp_path)
arch.salva_nome("Densa", _dati(metodo="densa"), mappa_densa)
arch.salva_nome("Altra", _dati())
arch.attiva("camera:a", "Densa")
arch.attiva("camera:b", "Densa")
arch.attiva("camera:c", "Altra")
assert arch.leggi_mappa("camera:a") is not None
assert arch.elimina_nome("DENSA") is True
assert arch.leggi_nome("Densa") is None and not list((tmp_path / "calibrazioni").glob("*.npz"))
assert arch.attiva_di("camera:a") is None and arch.attiva_di("camera:b") is None
assert arch.leggi_mappa("camera:a") is None
assert arch.attiva_di("camera:c") == "Altra"
assert json.loads((tmp_path / "attive.json").read_text(encoding="utf-8")) == {"camera:c": "Altra"}
assert arch.elimina_nome("Densa") is False # gia' tolta
# ---- mappa
def test_leggi_mappa_risolve_l_attiva_con_cache(cal, tmp_path, mappa_densa):
arch = cal.Archivio(tmp_path)
assert arch.leggi_mappa("camera:a") is None
arch.salva_nome("Densa", _dati(metodo="densa"), mappa_densa)
assert arch.leggi_mappa("camera:a") is None # non attiva su camera:a
arch.attiva("camera:a", "Densa")
prima = arch.leggi_mappa("camera:a")
assert prima is not None and arch.leggi_mappa("camera:a") is prima # cache in memoria
assert arch.leggi_mappa("camera:b") is None
assert not list(tmp_path.rglob("*.tmp*")) # niente temporanei rimasti
arch.salva_nome("Densa", _dati(metodo="densa"), mappa_densa, sovrascrivi=True) # file riscritto
assert arch.leggi_mappa("camera:a") is not prima # cache invalidata
arch.salva_nome("Densa", _dati(), sovrascrivi=True) # sovrascritta con una planare: la mappa sparisce
assert arch.leggi_mappa("camera:a") is None and not list(tmp_path.rglob("*.npz"))
# ---- migrazione dal formato vecchio
def _vecchia(cartella, cal, source, mappa=None, **extra):
"""Scrive una calibrazione nel formato vecchio (un JSON per sorgente, + .npz) come faceva Archivio.salva."""
dati = {"source": source, **_dati(**extra)}
dati.pop("origine")
nudo = cal.Archivio.__new__(cal.Archivio) # senza costruttore: niente migrazione mentre si prepara
nudo.cartella = cartella
percorso = nudo._file(source)
cartella.mkdir(parents=True, exist_ok=True)
percorso.write_text(json.dumps(dati), encoding="utf-8")
if mappa is not None:
mappa.save(str(percorso.with_suffix(".npz")))
return percorso
def test_migrazione_formato_vecchio(cal, tmp_path, mappa_densa):
v1 = _vecchia(tmp_path, cal, "camera:601020", mappa_densa, metodo="densa", px_per_mm=7.0)
v2 = _vecchia(tmp_path, cal, "camera:folder", px_per_mm=9.0)
(tmp_path / "settaggi").mkdir()
(tmp_path / "settaggi" / v1.name).write_text("{}", encoding="utf-8") # non si tocca
arch = cal.Archivio(tmp_path)
assert not v1.exists() and not v1.with_suffix(".npz").exists() and not v2.exists()
assert {v["nome"] for v in arch.elenco()} == {"camera:601020", "camera:folder"}
assert arch.attiva_di("camera:601020") == "camera:601020" and arch.attiva_di("camera:folder") == "camera:folder"
letta = arch.leggi("camera:601020")
assert letta["px_per_mm"] == 7.0 and letta["source"] == "camera:601020"
assert arch.leggi_mappa("camera:601020") is not None # il .npz ha seguito il JSON
assert len(list((tmp_path / "calibrazioni").glob("*.npz"))) == 1
assert (tmp_path / "settaggi" / v1.name).read_text(encoding="utf-8") == "{}"
def test_migrazione_idempotente(cal, tmp_path):
_vecchia(tmp_path, cal, "camera:a")
cal.Archivio(tmp_path)
prima = sorted(p.name for p in tmp_path.rglob("*") if p.is_file())
arch = cal.Archivio(tmp_path) # secondo avvio
arch.migra()
assert sorted(p.name for p in tmp_path.rglob("*") if p.is_file()) == prima
assert [v["nome"] for v in arch.elenco()] == ["camera:a"]
def test_migrazione_nome_gia_preso(cal, tmp_path):
arch = cal.Archivio(tmp_path)
arch.salva_nome("camera:a", _dati(px_per_mm=1.0, calibrated_at="2020-01-01T00:00:00+00:00"))
_vecchia(tmp_path, cal, "camera:a", px_per_mm=5.0)
arch.migra()
nomi = {v["nome"]: v["px_per_mm"] for v in arch.elenco()}
assert nomi == {"camera:a": 1.0, "camera:a (2)": 5.0}
assert arch.attiva_di("camera:a") == "camera:a (2)" # la sorgente usa la sua, non quella gia' presente
# un altro file con lo stesso nome -> (3)
_vecchia(tmp_path, cal, "camera:A", px_per_mm=6.0) # nome uguale senza maiuscole
arch.migra()
assert "camera:A (3)" in {v["nome"] for v in arch.elenco()}
def test_migrazione_ignora_json_senza_source_e_attive(cal, tmp_path):
tmp_path.mkdir(exist_ok=True)
(tmp_path / "altro.json").write_text(json.dumps({"x": 1}), encoding="utf-8")
(tmp_path / "rotto.json").write_text("{non json", encoding="utf-8")
arch = cal.Archivio(tmp_path)
arch.salva_nome("A", _dati())
arch.attiva("source", "A") # una sorgente chiamata «source» non deve far scambiare attive.json per un vecchio file
arch.migra()
assert (tmp_path / "altro.json").exists() and (tmp_path / "rotto.json").exists()
assert arch.attiva_di("source") == "A" and [v["nome"] for v in arch.elenco()] == ["A"]
def test_cartella_assente_non_da_errore(cal, tmp_path):
arch = cal.Archivio(tmp_path / "non" / "esiste")
assert arch.elenco() == [] and arch.leggi("s") is None and arch.leggi_mappa("s") is None
arch.scollega("s")
assert arch.elimina_nome("x") is False
+250
View File
@@ -0,0 +1,250 @@
"""Test delle rotte dell'archivio unico di calibrazioni con nome (#91): scripts/camera_service.py.
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
Sorgenti: camera:folder su cartelle temporanee (520x400 scacchiera piana, 640x480 densa).
Solo tmp_path: niente ProgramData, niente camera vera.
"""
import json
import cv2
import numpy as np
import pytest
from fastapi.testclient import TestClient
from test_calibrazione import ( # fixture e helper della calibrazione (#83/#84)
CAMPI, CHIAVI_ATTIVA, CHIAVI_DENSA, CHIAVI_PLANARE, SORGENTE, _carica, _png,
carica_su, cartella, cartella_densa, client, client_denso, salva_con_nome,
una_scacchiera, una_scacchiera_densa,
)
__all__ = ["cartella", "cartella_densa", "client", "client_denso"] # le fixture importate restano usate
def _calcola_file(c, **extra):
"""Calcolo planare da un'immagine (520x400): ritorna la risposta JSON."""
r = c.post("/camera/calibrate", data={"source": SORGENTE, **CAMPI, **extra},
files={"image": ("scacco.png", _png(una_scacchiera()), "image/png")})
assert r.status_code == 200, r.text
return r.json()
# ---- POST /camera/calibrate
def test_calibrate_senza_source_con_immagine_ok(client):
r = client.post("/camera/calibrate", data=CAMPI,
files={"image": ("C:\\foto\\scacco.png", _png(una_scacchiera()), "image/png")})
assert r.status_code == 200, r.text
assert r.json()["origine"] == "file:scacco.png" # solo il nome del file, non il percorso
def test_calibrate_senza_source_e_senza_immagine_400(client):
r = client.post("/camera/calibrate", data=CAMPI)
assert r.status_code == 400 and "source" in r.json()["detail"]
def test_calibrate_da_sorgente_ha_origine_camera_e_id(client):
r = client.post("/camera/calibrate", data={"source": SORGENTE, **CAMPI})
assert r.status_code == 200, r.text
corpo = r.json()
assert corpo["origine"] == SORGENTE and corpo["calibrated_at"]
assert len(corpo["calcolo_id"]) == 32 and int(corpo["calcolo_id"], 16) >= 0 # esadecimale
assert corpo["calcolo_id"] != client.post("/camera/calibrate", data={"source": SORGENTE, **CAMPI}).json()["calcolo_id"]
# ---- POST /camera/calibrazioni
def test_salva_201_con_metadati(client):
calcolo = _calcola_file(client)
r = salva_con_nome(client, calcolo, " Banco 1 ")
assert r.status_code == 201, r.text
meta = r.json()
assert meta["nome"] == "Banco 1" and meta["origine"] == "file:scacco.png"
assert (meta["image_width"], meta["image_height"]) == (520, 400)
assert meta["metodo"] == "planare" and meta["attiva_su"] == []
assert meta["px_per_mm"] == pytest.approx(20.0, rel=0.005)
# lo stesso calcolo si puo' salvare anche con un altro nome
assert salva_con_nome(client, calcolo, "Banco 2").status_code == 201
def test_salva_calcolo_sconosciuto_404(client):
r = client.post("/camera/calibrazioni", json={"calcolo_id": "0" * 32, "nome": "X"})
assert r.status_code == 404 and r.json()["detail"] == "calcolo non più disponibile: ricalcolare"
def test_salva_calcolo_scaduto_dopo_otto_calcoli_404(client):
primo = _calcola_file(client)
ultimo = primo
for _ in range(8): # il nono calcolo spinge fuori il primo
ultimo = _calcola_file(client)
assert salva_con_nome(client, primo, "Vecchio").status_code == 404
assert salva_con_nome(client, ultimo, "Nuovo").status_code == 201
@pytest.mark.parametrize("nome", ["", " ", "x" * 61, "a\x01b", None, 7])
def test_salva_nome_non_valido_400(client, nome):
r = client.post("/camera/calibrazioni", json={"calcolo_id": _calcola_file(client)["calcolo_id"], "nome": nome})
assert r.status_code == 400, r.text
assert client.get("/camera/calibrazioni").json()["calibrazioni"] == []
def test_salva_corpo_incompleto_400(client):
assert client.post("/camera/calibrazioni", json={"nome": "X"}).status_code == 400
calcolo = _calcola_file(client)
r = client.post("/camera/calibrazioni", json={"calcolo_id": calcolo["calcolo_id"], "nome": "X", "sovrascrivi": "si"})
assert r.status_code == 400
def test_salva_nome_gia_usato_409_e_sovrascrivi(client):
a = _calcola_file(client)
assert salva_con_nome(client, a, "Banco").status_code == 201
r = salva_con_nome(client, a, "BANCO") # senza distinguere maiuscole
assert r.status_code == 409 and r.json()["detail"] == "esiste già una calibrazione «Banco»"
assert len(client.get("/camera/calibrazioni").json()["calibrazioni"]) == 1
r = salva_con_nome(client, a, "BANCO", sovrascrivi=True)
assert r.status_code == 201 and r.json()["nome"] == "BANCO"
voci = client.get("/camera/calibrazioni").json()["calibrazioni"]
assert [v["nome"] for v in voci] == ["BANCO"]
# ---- GET /camera/calibrazioni
def test_elenco_vuoto_e_ordine(client):
assert client.get("/camera/calibrazioni").json() == {"calibrazioni": [], "attiva": None}
prima = _calcola_file(client)
salva_con_nome(client, prima, "prima")
seconda = _calcola_file(client)
seconda_ts = seconda["calibrated_at"]
salva_con_nome(client, seconda, "seconda")
voci = client.get("/camera/calibrazioni").json()["calibrazioni"]
assert {v["nome"] for v in voci} == {"prima", "seconda"}
assert [v["calibrated_at"] for v in voci] == sorted((v["calibrated_at"] for v in voci), reverse=True)
assert set(voci[0]) == {"nome", "origine", "image_width", "image_height", "px_per_mm", "mm_per_px",
"metodo", "square_mm", "calibrated_at", "attiva_su"}
assert seconda_ts >= prima["calibrated_at"]
def test_elenco_con_source_dice_l_attiva(client):
salva_con_nome(client, _calcola_file(client), "Banco")
salva_con_nome(client, _calcola_file(client), "Altra")
assert carica_su(client, SORGENTE, "banco").status_code == 200
r = client.get("/camera/calibrazioni", params={"source": SORGENTE}).json()
assert r["attiva"] == "Banco"
attiva_su = {v["nome"]: v["attiva_su"] for v in r["calibrazioni"]}
assert attiva_su == {"Banco": [SORGENTE], "Altra": []}
assert client.get("/camera/calibrazioni", params={"source": "camera:altra"}).json()["attiva"] is None
assert client.get("/camera/calibrazioni", params={"source": " "}).status_code == 400
# ---- DELETE /camera/calibrazioni
def test_elimina_204_404_e_toglie_dalle_sorgenti(client):
salva_con_nome(client, _calcola_file(client), "Banco")
carica_su(client, SORGENTE, "Banco")
assert client.delete("/camera/calibrazioni", params={"nome": "BANCO"}).status_code == 204
assert client.get("/camera/calibrazioni").json()["calibrazioni"] == []
assert client.get("/camera/calibration", params={"source": SORGENTE}).status_code == 404 # non piu' attiva
assert json.loads((client.config / "attive.json").read_text(encoding="utf-8")) == {}
assert client.delete("/camera/calibrazioni", params={"nome": "Banco"}).status_code == 404
assert client.delete("/camera/calibrazioni").status_code == 404 # senza nome
# ---- PUT /camera/calibrazione-attiva
def test_carica_risoluzione_uguale_200_come_get(client):
salva_con_nome(client, _calcola_file(client), "Banco") # 520x400 = la scacchiera in cartella
r = carica_su(client, SORGENTE, "banco")
assert r.status_code == 200, r.text
assert set(r.json()) == CHIAVI_PLANARE | CHIAVI_ATTIVA
assert r.json()["nome"] == "Banco" and r.json()["source"] == SORGENTE
assert client.get("/camera/calibration", params={"source": SORGENTE}).json() == r.json()
def test_carica_risoluzione_diversa_409_e_anteprima_continua(client_denso):
# calibrazione da file 520x400, sorgente 640x480
salva_con_nome(client_denso, _calcola_file(client_denso), "Piccola")
r = carica_su(client_denso, SORGENTE, "Piccola")
assert r.status_code == 409
assert r.json()["detail"] == ("la calibrazione «Piccola» è per immagini 520×400, "
"la sorgente dà 640×480: non si può caricare")
assert client_denso.get("/camera/calibration", params={"source": SORGENTE}).status_code == 404 # nulla attivato
# l'acquisizione continua: grab e anteprima rispondono, frame non raddrizzato
g = client_denso.post("/camera/grab", json={"source": SORGENTE})
assert g.status_code == 200 and g.headers["x-raddrizzato"] == "no"
a = client_denso.post("/camera/anteprima", json={"source": SORGENTE})
assert a.status_code == 200 and a.headers["x-raddrizzato"] == "no"
assert (a.headers["x-larghezza"], a.headers["x-altezza"]) == ("640", "480")
def test_carica_risoluzione_diversa_non_scalza_l_attiva_precedente(client_denso):
densa = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"}).json()
salva_con_nome(client_denso, densa, "Giusta")
salva_con_nome(client_denso, _calcola_file(client_denso), "Sbagliata")
assert carica_su(client_denso, SORGENTE, "Giusta").status_code == 200
assert carica_su(client_denso, SORGENTE, "Sbagliata").status_code == 409
assert client_denso.get("/camera/calibration", params={"source": SORGENTE}).json()["nome"] == "Giusta"
assert client_denso.post("/camera/grab", json={"source": SORGENTE}).headers["x-raddrizzato"] == "si"
def test_carica_densa_su_sorgente_stessa_risoluzione_raddrizza(client_denso):
densa = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"}).json()
assert salva_con_nome(client_denso, densa, "Densa").json()["metodo"] == "densa"
r = carica_su(client_denso, SORGENTE, "Densa")
assert r.status_code == 200 and set(r.json()) == CHIAVI_DENSA | CHIAVI_ATTIVA
assert client_denso.post("/camera/anteprima", json={"source": SORGENTE}).headers["x-raddrizzato"] == "si"
def test_carica_nome_sconosciuto_404(client):
r = carica_su(client, SORGENTE, "nessuna")
assert r.status_code == 404
assert client.put("/camera/calibrazione-attiva", json={"source": SORGENTE}).status_code == 404 # senza nome
assert client.put("/camera/calibrazione-attiva", json={"nome": "x"}).status_code == 400 # senza source
def test_carica_su_sorgente_inesistente_404(client):
salva_con_nome(client, _calcola_file(client), "Banco")
assert carica_su(client, "camera:non-esiste", "Banco").status_code == 404
def test_carica_senza_risoluzione_registrata_409(cartella, tmp_path):
_carica()
import calibrazione
config = tmp_path / "config_senza"
calibrazione.Archivio(config).salva_nome("Senza", {"px_per_mm": 10.0, "calibrated_at": "2026-01-01T00:00:00+00:00"})
with TestClient(_carica().create_app(cartella, config_dir=config)) as c:
r = carica_su(c, SORGENTE, "Senza")
assert r.status_code == 409
assert r.json()["detail"] == "la calibrazione «Senza» non ha la risoluzione registrata: non si può caricare"
# ---- contratto verso il backend e migrazione all'avvio
def test_get_calibration_contratto_per_il_backend(client):
# src/backend/services/vision_service.py legge questi campi
salva_con_nome(client, _calcola_file(client), "Banco")
carica_su(client, SORGENTE, "Banco")
dati = client.get("/camera/calibration", params={"source": SORGENTE}).json()
for campo in ("px_per_mm", "mm_per_px", "image_width", "image_height", "calibrated_at", "metodo"):
assert campo in dati
assert client.get("/camera/calibration", params={"source": "camera:altra"}).json() == {
"detail": "sorgente non calibrata"}
def test_avvio_migra_il_formato_vecchio(cartella, tmp_path):
_carica()
import calibrazione
config = tmp_path / "config_vecchia"
config.mkdir()
nudo = calibrazione.Archivio.__new__(calibrazione.Archivio) # per avere il nome file del formato vecchio
nudo.cartella = config
vecchio = nudo._file(SORGENTE)
vecchio.write_text(json.dumps({"source": SORGENTE, "px_per_mm": 20.0, "mm_per_px": 0.05,
"image_width": 520, "image_height": 400, "metodo": "planare",
"calibrated_at": "2026-10-01T10:00:00+00:00"}), encoding="utf-8")
with TestClient(_carica().create_app(cartella, config_dir=config)) as c:
dati = c.get("/camera/calibration", params={"source": SORGENTE}).json()
assert dati["nome"] == SORGENTE and dati["source"] == SORGENTE and dati["px_per_mm"] == 20.0
voci = c.get("/camera/calibrazioni", params={"source": SORGENTE}).json()
assert [v["nome"] for v in voci["calibrazioni"]] == [SORGENTE] and voci["attiva"] == SORGENTE
assert voci["calibrazioni"][0]["attiva_su"] == [SORGENTE]
assert not vecchio.exists()
+320
View File
@@ -0,0 +1,320 @@
"""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
def test_apertura_chiede_il_frame_piu_recente(cartella, tmp_path, monkeypatch):
# #89: solo_ultimo_frame() una volta sola all'apertura, non a ogni richiesta
class ConUltimoFrame(Finta):
chiamate = 0
def solo_ultimo_frame(self, attivo=True):
self.chiamate += 1
finta = ConUltimoFrame()
with _client(cartella, tmp_path / "cfg", monkeypatch, finta) as c:
for _ in range(3):
assert c.get("/camera/parametri", params={"source": SORGENTE}).status_code == 200
assert finta.chiamate == 1
def test_frame_piu_recente_non_impostabile_non_blocca_l_apertura(cartella, tmp_path, monkeypatch, caplog):
# #89: producer senza il nodo -> avviso nel log, apertura riuscita
class SenzaNodo(Finta):
def solo_ultimo_frame(self, attivo=True):
raise CameraStateError("questo producer non espone StreamBufferHandlingMode")
with caplog.at_level("WARNING"):
with _client(cartella, tmp_path / "cfg", monkeypatch, SenzaNodo()) as c:
assert c.get("/camera/parametri", params={"source": SORGENTE}).status_code == 200
assert "frame piu' recente non impostabile" in caplog.text
def test_sorgente_senza_solo_ultimo_frame_si_apre(cartella, tmp_path, monkeypatch):
# #89: FolderCamera e simili non hanno il metodo: nessun errore
assert not hasattr(Finta(), "solo_ultimo_frame")
with _client(cartella, tmp_path / "cfg", monkeypatch, Finta()) 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
+54
View File
@@ -0,0 +1,54 @@
"""Test della pagina Acquisizione camera (#87): contenuto statico di PAGINA, senza servizio."""
import importlib.util
import re
from pathlib import Path
_FILE = Path(__file__).resolve().parents[1] / "calibrazione_pagina.py"
_spec = importlib.util.spec_from_file_location("calibrazione_pagina", _FILE)
_mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_mod)
PAGINA = _mod.PAGINA
def test_titolo_e_stile_scuro():
assert "<title>Acquisizione camera</title>" in PAGINA
assert "#0f172a" in PAGINA and "#1e293b" in PAGINA and "#3b82f6" in PAGINA
def test_rotte_usate():
for rotta in ("/camera/sources", "/camera/anteprima", "/camera/parametri",
"/camera/parametri/salva", "/camera/calibrate", "/camera/calibration"):
assert '"' + rotta in PAGINA, rotta
def test_offline_e_senza_finestre_del_browser():
for vietato in ("alert(", "confirm(", "prompt(", "http://", "https://"):
assert vietato not in PAGINA, vietato
def test_id_principali():
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
attesi = {"sorgente", "avvia", "ferma", "scatta", "mirinoCasella", "immagine",
"nitidezza", "nitidezzaMax", "saturati", "media", "dimensioni",
"parametri", "salvaParametri", "quadrato", "acquisisci", "caricaImmagine", "fileImmagine",
"mostraCal", "salva", "stato", "statoSorgente"} # #91: carica/elimina sono ora per riga; #92: file e calibrata nell'Acquisizione
assert attesi <= ids, attesi - ids
# ogni id usato dal JS con $("...") esiste nel markup
usati = set(re.findall(r'\$\("([^"]+)"\)', PAGINA))
assert usati <= ids, usati - ids
def test_sezioni_e_testi_italiani():
for testo in ("Sorgente", "Anteprima", "Parametri camera", "Calibrazione",
"Salva come predefiniti", "Carica immagine da file...", "Questa sorgente non ha parametri regolabili",
"Mostra immagine calibrata"): # #92
assert testo in PAGINA, testo
assert "it-IT" in PAGINA # numeri con la virgola
assert "Choose File" not in PAGINA
def test_localstorage_protetto():
# ogni uso di localStorage sta dentro un try
for m in re.finditer(r"localStorage[.]", PAGINA):
assert "try {" in PAGINA[max(0, m.start() - 200):m.start()]
+114
View File
@@ -0,0 +1,114 @@
"""Test della pagina a due tab (#91, #92): contenuto statico di PAGINA, senza servizio."""
import importlib.util
import re
from pathlib import Path
_FILE = Path(__file__).resolve().parents[1] / "calibrazione_pagina.py"
_spec = importlib.util.spec_from_file_location("calibrazione_pagina_tab", _FILE)
_mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_mod)
PAGINA = _mod.PAGINA
def test_due_tab_con_ruolo_e_selezione():
assert re.search(r'<button id="tabAcq"[^>]*role="tab"[^>]*aria-selected="true"', PAGINA)
assert re.search(r'<button id="tabCal"[^>]*role="tab"[^>]*aria-selected="false"', PAGINA)
assert ">Acquisizione</button>" in PAGINA and ">Calibrazione</button>" in PAGINA
assert 'role="tablist"' in PAGINA and 'role="tabpanel"' in PAGINA
assert 'aria-selected", cal ?' in PAGINA # la selezione cambia da JS
assert '"tmf.acq.tab"' in PAGINA # tab scelta ricordata (via memoria(), protetta)
def test_sorgente_in_cima_poi_riquadri():
# #92: Sorgente, anteprima e parametri stanno nel pannello Acquisizione; Sorgente prima dei riquadri
i_acq = PAGINA.index('id="pannelloAcq"')
i_cal = PAGINA.index('id="pannelloCal"')
i_sorg = PAGINA.index('id="sezioneSorgente"')
i_ant = PAGINA.index('id="sezioneAnteprima"')
i_riq = PAGINA.index('id="riquadri"')
assert i_acq < i_sorg < i_ant < i_riq < PAGINA.index('id="sezioneParametri"') < i_cal
assert PAGINA.index('id="sezioneCalibrazione"') > i_cal
# barra: casella calibrata e mirino; la vecchia casella «Immagine grezza» non c'e' piu'
assert 'id="mostraCal"' in PAGINA and "Mostra immagine calibrata" in PAGINA
assert 'id="grezzo"' not in PAGINA and "Immagine grezza (senza correzione)" not in PAGINA
# riquadri: «Immagine» sempre visibile, «Calibrata» nascosta finche' la casella e' spenta
assert re.search(r'<div class="riquadro" id="riquadroCal" hidden>', PAGINA)
assert "grezzo: true" in PAGINA
def test_id_del_pannello_calibrazione():
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
attesi = {"calSorgente", "quadrato", "acquisisci", "nomeCal", "salva", "msgSalva", "calibrazioni",
"msgElenco", "stato", "risultato", "overlay",
"caricaImmagine", "fileImmagine", "rimuoviImmagine", "riquadroCal", "immagineCal", "mirinoCal"}
assert attesi <= ids, attesi - ids
usati = set(re.findall(r'\$\("([^"]+)"\)', PAGINA))
assert usati <= ids, usati - ids
assert len(re.findall(r'id="([^"]+)"', PAGINA)) == len(ids) # nessun id doppio
def test_calibrazione_senza_file_ne_radio():
# #92: l'immagine da file e' una sorgente di acquisizione, non un'opzione della calibrazione
for sparito in ("origFile", "origSorgente", "scegliFile", "nomeFile", 'type="radio"', "Immagine da file", "Sorgente selezionata"):
assert sparito not in PAGINA, sparito
# l'unico input file e' nella sezione Sorgente, in cima alla tab Acquisizione
assert PAGINA.count('type="file"') == 1
assert PAGINA.index('type="file"') < PAGINA.index('id="pannelloCal"')
assert "immagine grezza dalla tab Acquisizione" in PAGINA
# il calcolo manda solo la sorgente (e il lato), mai il file
assert 'f.append("source", sorgente)' in PAGINA and 'f.append("image"' not in PAGINA.split("async function calcola")[1].split("async function salvaNome")[0]
def test_testi_del_pannello():
for testo in ("Camera o immagine", "Carica immagine da file...", "Rimuovi immagine", "Calcola",
"Calibrazioni salvate", "risoluzione diversa", "Calibrata",
"Carica", "Elimina", "Scollega", "Sovrascrivi «", "Clic ancora", "Nome",
"calibrata: ", "non calibrata"):
assert testo in PAGINA, testo
def test_rotte_del_contratto():
for rotta in ("/camera/calibrazioni", "/camera/calibrazione-attiva", "/camera/calibrate",
"/camera/calibration?source=", "/camera/immagini", "/camera/immagini?nome=",
"/camera/anteprima/calibrata"): # #92
assert '"' + rotta in PAGINA, rotta
for parola in ("calcolo_id", "sovrascrivi", "method: \"PUT\"", "method: \"DELETE\"", "X-Larghezza", "X-Altezza"):
assert parola in PAGINA, parola
# il calcolo non salva piu': il campo save sparisce
assert 'append("save"' not in PAGINA
def test_nessuna_finestra_del_browser_e_offline():
for vietato in ("alert(", "confirm(", "prompt(", "http://", "https://"):
assert vietato not in PAGINA, vietato
def test_acquisizione_di_87_90_conservata():
# anteprima, indicatori, mirino, grezzo, parametri camera e ciclo a 100 ms restano
for parola in ("Parametri camera", "Mirino centrale",
"Math.max(100 - (Date.now() - t0), 0)", "/camera/anteprima", "/camera/parametri"):
assert parola in PAGINA, parola
def test_pagina_stretta():
assert "@media (max-width: 500px)" in PAGINA
# #91: elenco a schede, niente tabella larga ne' scorrimento orizzontale
assert 'id="calibrazioni" class="elenco"' in PAGINA and "overflow-x" not in PAGINA
assert "Lettura dei parametri..." in PAGINA
def test_ciclo_sospeso_nella_tab_calibrazione():
# #92: entrando in Calibrazione il ciclo si ferma e poi riprende; Calcola aspetta le richieste in volo
assert "A.riprendi = true; fermaAnteprima();" in PAGINA
assert "if (!cal && A.riprendi)" in PAGINA
calcola = PAGINA.split("async function calcola")[1].split("async function salvaNome")[0]
assert "fermaAnteprima();" in calcola and "while (A.inVolo)" in calcola
def test_calibrata_dopo_anteprima_e_dopo_carica():
# #92: la calibrata segue ogni anteprima grezza riuscita e si aggiorna dopo Carica/Scollega; 404/409 al posto dell'immagine
assert "await aggiornaCalibrata(lato);" in PAGINA
assert PAGINA.count("aggiornaCalibrata();") >= 3
assert "messaggioCalibrata(await dettaglio(r))" in PAGINA
assert 'decodeURIComponent(r.headers.get("X-Calibrazione")' in PAGINA
+103 -14
View File
@@ -4,8 +4,11 @@ It does not know, and must not know, whether the server or the station computed
the numbers: that is what lets a station's configuration change without touching
the frontend or the recipes.
"""
import base64
import binascii
import io
import json
from typing import Literal
from fastapi.responses import FileResponse
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
@@ -26,7 +29,7 @@ from src.backend.models.api.vision import (
from src.backend.models.orm.vision import VisionResult
from src.backend.models.orm.task import RecipeTask
from src.backend.models.orm.user import User
from src.backend.services import vision_service
from src.backend.services import station_service, vision_service
router = APIRouter(prefix="/api/vision", tags=["vision"])
@@ -185,6 +188,30 @@ def _422(errors: list[str]) -> HTTPException:
)
async def _store_validate_assign(
task: RecipeTask, graph: dict, placed: dict[str, bytes],
) -> tuple[str, dict]:
"""Scrive la cartella ricetta, la fa validare dal worker, la assegna al task.
Condiviso da upload_recipe e dxf-compare con save (#83): stesso
comportamento, un solo posto. Non committa. Ritorna (recipe_dir, check).
"""
# The folder is content-addressed and immutable; writing it before the
# validation is harmless (an orphan folder is never referenced) and the
# worker needs it on disk to validate meta files.
try:
recipe_dir = vision_service.store_recipe_folder(task.id, graph, placed)
except ValueError as exc:
raise _422([str(exc)])
check = await vision_service.validate_recipe(graph, recipe_dir)
if not check["ok"]:
raise _422([str(e) for e in check["errors"]] or ["the recipe is not valid"])
task.vision_json = graph
task.vision_recipe_dir = recipe_dir
return recipe_dir, check
@router.post("/tasks/{task_id}/recipe", response_model=VisionRecipeUploadResponse)
async def upload_recipe(
task_id: int,
@@ -218,19 +245,7 @@ async def upload_recipe(
if errors:
raise _422(errors)
# The folder is content-addressed and immutable; writing it before the
# validation is harmless (an orphan folder is never referenced) and the
# worker needs it on disk to validate meta files.
try:
recipe_dir = vision_service.store_recipe_folder(task.id, graph, placed)
except ValueError as exc:
raise _422([str(exc)])
check = await vision_service.validate_recipe(graph, recipe_dir)
if not check["ok"]:
raise _422([str(e) for e in check["errors"]] or ["the recipe is not valid"])
task.vision_json = graph
task.vision_recipe_dir = recipe_dir
recipe_dir, check = await _store_validate_assign(task, graph, placed)
await db.commit()
return VisionRecipeUploadResponse(
task_id=task.id,
@@ -325,3 +340,77 @@ async def preview(
) -> dict:
task = await _task_or_404(db, task_id)
return await vision_service.preview(db, task, await _read_image(image))
@router.post("/tasks/{task_id}/recipe/dxf-compare")
async def create_dxf_compare_recipe(
task_id: int,
image: UploadFile = File(...),
dxf: UploadFile = File(...),
station_id: int | None = Form(None, gt=0),
px_per_mm: float | None = Form(None, gt=0),
band_mm: float = Form(0.1, gt=0),
min_points_pct: float = Form(95, ge=0, le=100),
polarity: Literal["dark", "light"] = Form("dark"),
save: bool = Form(False),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_maker),
) -> dict:
"""Ricetta DXF compare da un'immagine e un DXF, senza Studio (#83).
La scala e' `px_per_mm`, o quella della stazione (letta dall'acquisitore).
Con save=false si vede il risultato e non si scrive nulla; con save=true la
ricetta va sul task come con l'upload, e se il task non ha nessuna quota di
visione nasce la quota «Punti in tolleranza».
"""
task = await _task_or_404(db, task_id)
if task.task_type not in vision_service.VISION_TASK_TYPES:
raise _422([f"task type {task.task_type!r} does not run a vision recipe"])
station = None
if station_id is not None:
station = await station_service.get_station(db, station_id) # 404 se non esiste
if px_per_mm is None:
calibration, reason = await vision_service.fetch_station_calibration(station)
if calibration is None:
# irraggiungibile e non calibrata sono due guasti diversi per chi lo legge
raise _422([
"acquisitore della stazione non raggiungibile"
if reason == vision_service.CAL_UNREACHABLE else "stazione non calibrata"
])
px_per_mm = float(calibration["px_per_mm"])
image_bytes = await _read_image(image)
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"])
body = await vision_service.dxf_compare_recipe(
image_bytes, dxf_bytes,
px_per_mm=px_per_mm, band_mm=band_mm, min_points_pct=min_points_pct,
polarity=polarity,
worker_url=(station.vision_worker_url or None) if station is not None else None,
)
# i file stanno nella ricetta salvata, non nella risposta al client
files = body.pop("files")
response = {"result": body, "saved": False, "recipe_dir": None, "outputs": None}
if not save:
return response
uploads: list[tuple[str, bytes]] = []
for name, encoded in files.items():
try:
uploads.append((name, base64.b64decode(encoded, validate=True)))
except (binascii.Error, ValueError, TypeError):
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"the vision worker's answer was malformed: file {name!r} is not base64",
)
placed, errors = vision_service.match_recipe_files(body["graph"], uploads)
if errors:
raise _422(errors)
recipe_dir, check = await _store_validate_assign(task, body["graph"], placed)
await vision_service.ensure_points_quote(db, task, min_points_pct)
await db.commit()
response.update(saved=True, recipe_dir=recipe_dir, outputs=check["outputs"])
return response
+183 -2
View File
@@ -7,6 +7,7 @@ from __future__ import annotations
import hashlib
import json
import logging
import math
from pathlib import Path
from uuid import uuid4
@@ -24,6 +25,8 @@ from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
from src.backend.services import measurement_service
logger = logging.getLogger(__name__)
# Worker responses whose status and reason are forwarded as-is rather than
# flattened into a generic 502 - see `_call_worker`.
_FORWARDED_WORKER_STATUSES = frozenset({
@@ -140,6 +143,136 @@ async def _call_worker(
return report
async def call_worker_multipart(
path: str,
*,
files: dict,
data: dict,
worker_url: str | None = None,
timeout: float = 120.0,
) -> dict:
"""POST multipart generico al worker (#83): /recipes/dxf-compare.
Stessa traduzione degli errori di `_call_worker`: 504 sul timeout, 502 sul
trasporto o su una risposta non JSON-oggetto; 422/503/504 del worker si
inoltrano cosi' come sono. Worker della stazione o, se manca, il centrale.
"""
base = (worker_url or settings.vision_worker_url).rstrip("/")
try:
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.post(f"{base}{path}", files=files, data=data)
except httpx.TimeoutException as exc:
raise HTTPException(
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
detail=f"the vision worker did not answer in time: {exc} ({base})",
) from exc
except (httpx.TransportError, httpx.HTTPError) as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"the vision worker is unreachable: {exc} ({base})",
) from exc
if response.status_code in _FORWARDED_WORKER_STATUSES:
try:
detail = response.json().get("detail", response.text)
except ValueError:
detail = response.text
raise HTTPException(status_code=response.status_code, detail=detail)
if response.status_code != 200:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"the vision worker refused the job: {response.text}",
)
try:
body = response.json()
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"the vision worker's answer was malformed: not valid JSON ({exc})",
) from exc
if not isinstance(body, dict):
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="the vision worker's answer was malformed: expected a JSON object",
)
return body
async def dxf_compare_recipe(
image_bytes: bytes, dxf_bytes: bytes, *, px_per_mm: float, band_mm: float,
min_points_pct: float, polarity: str, worker_url: str | None = None,
) -> dict:
"""W2: ricetta dxf-compare da immagine + DXF (#83). Sostituita dai test."""
body = await call_worker_multipart(
"/recipes/dxf-compare",
files={
"image": ("frame.png", image_bytes, "image/png"),
"dxf": ("disegno.dxf", dxf_bytes, "application/octet-stream"),
},
data={
"px_per_mm": str(px_per_mm), "band_mm": str(band_mm),
"min_points_pct": str(min_points_pct), "polarity": polarity,
},
worker_url=worker_url,
)
# forma minima del contratto W2: senza graph e files non c'e' nulla da salvare
if not isinstance(body.get("graph"), dict) or not isinstance(body.get("files"), dict):
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="the vision worker's answer was malformed: missing graph or files",
)
return body
# Stati di fetch_station_calibration (#83)
CAL_OK = "ok"
CAL_NO_CAMERA = "no_camera" # la stazione non ha camera_url/camera_source
CAL_NOT_CALIBRATED = "not_calibrated" # l'acquisitore risponde 404 (o un valore inutilizzabile)
CAL_UNREACHABLE = "unreachable" # l'acquisitore non risponde
async def fetch_station_calibration(station: Station | None) -> tuple[dict | None, str]:
"""Legge la calibrazione dall'acquisitore della stazione (C1, #83).
GET {camera_url}/camera/calibration?source={camera_source}. La calibrazione
vive nell'acquisitore: TMF la legge, non la tiene. Ritorna (dict, "ok")
oppure (None, motivo) per distinguere «non calibrata» da «irraggiungibile».
Un px_per_mm non finito o <= 0 conta come non calibrata.
"""
if station is None or not station.camera_url or not station.camera_source:
return None, CAL_NO_CAMERA
url = f"{station.camera_url.rstrip('/')}/camera/calibration"
try:
# timeout breve: e' una lettura di servizio, non deve bloccare l'execute
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(url, params={"source": station.camera_source})
except httpx.HTTPError as exc:
logger.warning("acquisitore della stazione %s non raggiungibile: %s", station.code, exc)
return None, CAL_UNREACHABLE
if response.status_code == status.HTTP_404_NOT_FOUND:
return None, CAL_NOT_CALIBRATED
if response.status_code != 200:
logger.warning(
"acquisitore della stazione %s: HTTP %s", station.code, response.status_code,
)
return None, CAL_UNREACHABLE
try:
body = response.json()
except ValueError:
logger.warning("acquisitore della stazione %s: risposta non JSON", station.code)
return None, CAL_UNREACHABLE
px_per_mm = body.get("px_per_mm") if isinstance(body, dict) else None
if not (_is_measurable(px_per_mm) and px_per_mm > 0):
return None, CAL_NOT_CALIBRATED
return body, CAL_OK
async def get_station_calibration(station: Station | None) -> dict | None:
"""La calibrazione C1 della stazione, o None in ogni altro caso (#83)."""
calibration, _ = await fetch_station_calibration(station)
return calibration
async def validate_recipe(graph: dict, recipe_dir: str | None) -> dict:
"""Ask the worker's /validate whether a v2 recipe is loadable (#46).
@@ -365,11 +498,14 @@ async def execute_task(
)
# Worker della stazione della sessione di produzione (#45); senza run o senza URL: centrale
station_url = None
# Calibrazione letta dall'acquisitore della stazione (#83); None = non c'e'
calibration = None
if run is not None:
station = (await db.execute(
select(Station).where(Station.id == run.station_id)
)).scalar_one_or_none()
station_url = (station.vision_worker_url or None) if station is not None else None
calibration = await get_station_calibration(station)
if not task.vision_json:
raise HTTPException(
@@ -419,11 +555,15 @@ async def execute_task(
),
)
# Nessuna fonte di calibrazione esiste oggi (ne' task ne' stazione): non si
# passa `calibration`, la ricetta porta la sua (#46).
# La calibrazione viene dall'acquisitore della stazione (#83); se non c'e',
# non si passa `calibration` e la ricetta porta la sua (#46).
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, # #45
**worker_kwargs,
)
outputs = report["outputs"]
@@ -457,6 +597,8 @@ async def execute_task(
station_id=run.station_id if run is not None else None,
worker_url=(station_url or settings.vision_worker_url).rstrip("/"),
graph_snapshot=task.vision_json,
# copiata come il grafo (#83): ricalibrare dopo non cambia cio' che e' stato misurato
calibration_snapshot=calibration,
duration_ms=report.get("duration_ms"),
)
db.add(result)
@@ -601,3 +743,42 @@ async def preview(db: AsyncSession, task: RecipeTask, image_bytes: bytes) -> dic
return await _call_worker(
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
)
async def ensure_points_quote(
db: AsyncSession, task: RecipeTask, min_points_pct: float,
) -> bool:
"""Crea la quota «Punti in tolleranza» se il task non ha nessuna quota di visione (#83).
Una sola volta: se esiste gia' una quota con `vision_output`, non si tocca.
Se la versione ha gia' misure non si scrive (il copy-on-write sta nel router
dei task, qui non si aggira): ritorna False. Ritorna True se l'ha creata.
"""
from src.backend.services import recipe_service # import locale: evita cicli
existing = (await db.execute(
select(RecipeSubtask.id)
.where(RecipeSubtask.task_id == task.id)
.where(RecipeSubtask.vision_output.is_not(None))
.limit(1)
)).scalar_one_or_none()
if existing is not None:
return False
if await recipe_service.version_has_measurements(db, task.version_id):
return False
markers = (await db.execute(
select(RecipeSubtask.marker_number).where(RecipeSubtask.task_id == task.id)
)).scalars().all()
db.add(RecipeSubtask(
task_id=task.id,
marker_number=max(markers, default=0) + 1, # unico per task (uq_task_marker)
description="Punti in tolleranza",
nominal=100,
# limiti assoluti come nel resto delle quote: passa se punti% >= min_points_pct
ltl=min_points_pct,
utl=100,
unit="%",
vision_output="dxf.points_in_pct",
))
await db.flush()
return True
+194
View File
@@ -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()
@@ -0,0 +1,338 @@
"""#83: ricetta DXF compare da camera e calibrazione letta dall'acquisitore.
Il worker (W2) e l'acquisitore (C1) non girano nei test: si fingono, come
fanno gli altri test di visione. La calibrazione vive nell'acquisitore esterno,
TMF la legge via GET {camera_url}/camera/calibration.
"""
import base64
import json
import httpx
import pytest
from sqlalchemy import select
from src.backend.config import settings
from src.backend.models.orm.station import Station
from src.backend.models.orm.task import RecipeSubtask
from src.backend.models.orm.vision import VisionResult
from src.backend.services import vision_service
from src.backend.tests.conftest import auth_headers
from src.backend.tests.test_vision_recipe_upload import an_image, make_task
from src.backend.tests.test_vision_station_worker import ( # noqa: F401 (fixture worker_calls)
_camera_task, _execute, _station_with_run, worker_calls,
)
CAMERA_URL = "http://10.0.0.9:8200"
CAL = {
"source": "camera:A1", "px_per_mm": 12.5, "mm_per_px": 0.08, "rms_px": 0.2,
"n_corners": 40, "cols": 8, "rows": 5, "square_mm": 5.0, "image_width": 640,
"image_height": 480, "scale_spread_pct": 0.1, "calibrated_at": "2026-10-23T10:00:00",
}
GRAPH = {
"schema_version": 2, "name": "dxf-compare", "tools": [],
"meta": {"dxf": {"disegno": {"path": "disegno.dxf"}}, "calibration": {"path": "calibrazione.json"}},
}
OUTPUTS = [{"key": "dxf.points_in_pct", "quantity": "ratio", "unit": "%"}]
def _b64(data: bytes) -> str:
return base64.b64encode(data).decode()
@pytest.fixture(autouse=True)
def redirect_uploads(monkeypatch, tmp_path):
# upload_path = parents[2] / upload_dir: un assoluto sostituisce la base
monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
@pytest.fixture
def w2(monkeypatch):
"""Finto worker W2: ricorda le chiamate e risponde col corpo del contratto."""
calls: list[dict] = []
async def fake(image_bytes, dxf_bytes, **kwargs):
calls.append({"dxf": dxf_bytes, **kwargs})
return {
"graph": GRAPH,
"files": {
"disegno.dxf": _b64(dxf_bytes),
"calibrazione.json": _b64(json.dumps({"px_per_mm": kwargs["px_per_mm"]}).encode()),
},
"outputs": {"dxf.points_in_pct": 99.0},
"overlay": [], "outcome": "pass",
}
monkeypatch.setattr("src.backend.services.vision_service.dxf_compare_recipe", fake)
return calls
@pytest.fixture
def validate_ok(monkeypatch):
async def fake(graph, recipe_dir):
return {"ok": True, "errors": [], "outputs": OUTPUTS}
monkeypatch.setattr("src.backend.services.vision_service.validate_recipe", fake)
@pytest.fixture
def acquisitore(monkeypatch):
"""Finto C1: `state['cal']` = dict (200), None (404), 'down' (irraggiungibile)."""
state: dict = {"cal": CAL, "urls": []}
class _Resp:
def __init__(self, code, body=None):
self.status_code, self._body, self.text = code, body, ""
def json(self):
return self._body
class _Client:
def __init__(self, *a, **k):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
async def get(self, url, params=None):
state["urls"].append((url, params))
if state["cal"] == "down":
raise httpx.ConnectError("giu'")
if state["cal"] is None:
return _Resp(404)
return _Resp(200, state["cal"])
monkeypatch.setattr(httpx, "AsyncClient", _Client)
return state
async def _station(db_session, user, *, camera=True, worker_url=None, code="ST-C"):
station = Station(
code=code, name="Stazione C", active=True, created_by=user.id,
vision_worker_url=worker_url,
camera_url=CAMERA_URL if camera else None,
camera_source="camera:A1" if camera else None,
)
db_session.add(station)
await db_session.commit()
await db_session.refresh(station)
return station
def _post(client, user, task, *, save=False, **data):
return client.post(
f"/api/vision/tasks/{task.id}/recipe/dxf-compare",
headers=auth_headers(user),
files={
"image": ("p.png", an_image(), "image/png"),
"dxf": ("d.dxf", b"0\nEOF", "application/octet-stream"),
},
data={"save": str(save).lower(), **{k: str(v) for k, v in data.items() if v is not None}},
)
# --- lettura della calibrazione dall'acquisitore (C1) -------------------------
@pytest.mark.asyncio
async def test_fetch_calibration_ok(db_session, maker_user, acquisitore):
station = await _station(db_session, maker_user)
cal, reason = await vision_service.fetch_station_calibration(station)
assert (cal, reason) == (CAL, "ok")
assert acquisitore["urls"] == [(f"{CAMERA_URL}/camera/calibration", {"source": "camera:A1"})]
@pytest.mark.asyncio
async def test_fetch_calibration_reasons(db_session, maker_user, acquisitore):
station = await _station(db_session, maker_user)
acquisitore["cal"] = None
assert await vision_service.fetch_station_calibration(station) == (None, "not_calibrated")
acquisitore["cal"] = "down"
assert await vision_service.fetch_station_calibration(station) == (None, "unreachable")
assert await vision_service.get_station_calibration(station) is None
acquisitore["cal"] = {**CAL, "px_per_mm": 0} # valore inutilizzabile = non calibrata
assert await vision_service.fetch_station_calibration(station) == (None, "not_calibrated")
no_camera = await _station(db_session, maker_user, camera=False, code="ST-N")
assert await vision_service.fetch_station_calibration(no_camera) == (None, "no_camera")
assert await vision_service.fetch_station_calibration(None) == (None, "no_camera")
# --- B3: POST /api/vision/tasks/{id}/recipe/dxf-compare -----------------------
@pytest.mark.asyncio
async def test_dxf_compare_preview_writes_nothing(
client, db_session, maker_user, w2, acquisitore,
):
task, subtask = await make_task(db_session, maker_user)
station = await _station(db_session, maker_user, worker_url="http://10.0.0.7:8100")
resp = await _post(client, maker_user, task, station_id=station.id, band_mm=0.2,
min_points_pct=90, polarity="light")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["saved"] is False and body["recipe_dir"] is None and body["outputs"] is None
assert "files" not in body["result"] and body["result"]["graph"] == GRAPH
# px_per_mm dall'acquisitore, parametri e worker della stazione
assert w2[0]["px_per_mm"] == 12.5 and w2[0]["band_mm"] == 0.2
assert w2[0]["min_points_pct"] == 90 and w2[0]["polarity"] == "light"
assert w2[0]["worker_url"] == "http://10.0.0.7:8100"
await db_session.refresh(task)
assert task.vision_json is None and task.vision_recipe_dir is None
quotes = (await db_session.execute(
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id))).scalars().all()
assert [q.id for q in quotes] == [subtask.id] # nessuna quota nuova
assert not (settings.upload_path / "vision" / "recipes").exists()
@pytest.mark.asyncio
async def test_dxf_compare_save_stores_recipe_and_quote_once(
client, db_session, maker_user, w2, validate_ok,
):
task, _ = await make_task(db_session, maker_user)
resp = await _post(client, maker_user, task, save=True, px_per_mm=10, min_points_pct=95)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["saved"] is True and body["outputs"] == OUTPUTS
assert "files" not in body["result"]
folder = settings.upload_path / body["recipe_dir"]
assert (folder / "disegno.dxf").read_bytes() == b"0\nEOF"
assert json.loads((folder / "calibrazione.json").read_text()) == {"px_per_mm": 10.0}
await db_session.refresh(task)
assert task.vision_json == GRAPH and task.vision_recipe_dir == body["recipe_dir"]
quotes = (await db_session.execute(
select(RecipeSubtask).where(RecipeSubtask.vision_output == "dxf.points_in_pct")
.where(RecipeSubtask.task_id == task.id))).scalars().all()
assert len(quotes) == 1
q = quotes[0]
assert q.description == "Punti in tolleranza"
assert (float(q.nominal), float(q.ltl), float(q.utl)) == (100.0, 95.0, 100.0)
# secondo salvataggio: la quota non si duplica
task_id = task.id # dopo expire_all l'attributo scaduto non si rilegge in async
again = await _post(client, maker_user, task, save=True, px_per_mm=10)
assert again.status_code == 200
db_session.expire_all()
count = (await db_session.execute(
select(RecipeSubtask).where(RecipeSubtask.vision_output.is_not(None))
.where(RecipeSubtask.task_id == task_id))).scalars().all()
assert len(count) == 1
@pytest.mark.asyncio
async def test_dxf_compare_keeps_an_existing_vision_quote(
client, db_session, maker_user, w2, validate_ok,
):
task, subtask = await make_task(db_session, maker_user)
subtask.vision_output = "t1.diameter"
await db_session.commit()
resp = await _post(client, maker_user, task, save=True, px_per_mm=10)
assert resp.status_code == 200, resp.text
quotes = (await db_session.execute(
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id))).scalars().all()
assert len(quotes) == 1 # niente quota in piu'
@pytest.mark.asyncio
async def test_dxf_compare_uncalibrated_station_is_422(
client, db_session, maker_user, w2, acquisitore,
):
task, _ = await make_task(db_session, maker_user)
station = await _station(db_session, maker_user)
acquisitore["cal"] = None
resp = await _post(client, maker_user, task, station_id=station.id)
assert resp.status_code == 422
assert resp.json()["detail"] == ["stazione non calibrata"]
assert w2 == []
@pytest.mark.asyncio
async def test_dxf_compare_unreachable_acquirer_is_422_with_its_own_message(
client, db_session, maker_user, w2, acquisitore,
):
task, _ = await make_task(db_session, maker_user)
station = await _station(db_session, maker_user)
acquisitore["cal"] = "down"
resp = await _post(client, maker_user, task, station_id=station.id)
assert resp.status_code == 422
assert resp.json()["detail"] == ["acquisitore della stazione non raggiungibile"]
@pytest.mark.asyncio
async def test_dxf_compare_without_station_nor_scale_is_422(client, db_session, maker_user, w2):
task, _ = await make_task(db_session, maker_user)
resp = await _post(client, maker_user, task)
assert resp.status_code == 422 and resp.json()["detail"] == ["stazione non calibrata"]
@pytest.mark.asyncio
async def test_dxf_compare_needs_maker(client, db_session, maker_user, measurement_tec_user, w2):
task, _ = await make_task(db_session, maker_user)
resp = await _post(client, measurement_tec_user, task, px_per_mm=10)
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_dxf_compare_worker_422_is_forwarded(
client, db_session, maker_user, monkeypatch,
):
from fastapi import HTTPException
async def refuse(*a, **k):
raise HTTPException(status_code=422, detail="pezzo non trovato")
monkeypatch.setattr("src.backend.services.vision_service.dxf_compare_recipe", refuse)
task, _ = await make_task(db_session, maker_user)
resp = await _post(client, maker_user, task, px_per_mm=10)
assert resp.status_code == 422 and resp.json()["detail"] == "pezzo non trovato"
# --- execute: calibrazione della stazione -------------------------------------
@pytest.mark.asyncio
async def test_execute_passes_station_calibration_and_snapshots_it(
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, None)
station.camera_url, station.camera_source = CAMERA_URL, "camera:A1"
await db_session.commit()
await _execute(client, measurement_tec_user, task, run)
assert worker_calls[0]["calibration"] == {"px_per_mm": 12.5}
result = (await db_session.execute(select(VisionResult))).scalars().one()
assert result.calibration_snapshot == CAL
@pytest.mark.asyncio
async def test_execute_without_calibration_is_unchanged(
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, None)
station.camera_url, station.camera_source = CAMERA_URL, "camera:A1"
await db_session.commit()
acquisitore["cal"] = "down" # acquisitore giu': si misura come prima, senza calibrazione
await _execute(client, measurement_tec_user, task, run)
assert "calibration" not in worker_calls[0]
result = (await db_session.execute(select(VisionResult))).scalars().one()
assert result.calibration_snapshot is None
@pytest.mark.asyncio
async def test_execute_station_without_camera_never_asks(
client, db_session, measurement_tec_user, worker_calls, acquisitore,
):
recipe, task = await _camera_task(db_session, measurement_tec_user.id)
_, run = await _station_with_run(db_session, measurement_tec_user, recipe, None)
await _execute(client, measurement_tec_user, task, run)
assert acquisitore["urls"] == [] and "calibration" not in worker_calls[0]
@@ -1,4 +1,6 @@
"""Admin blueprint - user management."""
from urllib.parse import urlparse # #88: validazione url camera
import requests as http_requests # #88: lettura sorgenti dall'acquisitore (come maker.py)
from flask import Blueprint, flash, jsonify, redirect, render_template, request, session, url_for
from flask_babel import gettext as _
@@ -170,6 +172,39 @@ def api_toggle_active(user_id: int):
# --- Stations ---
@admin_bp.route("/api/camera/sources", methods=["GET"])
@login_required
@admin_required
def api_camera_sources():
"""Proxy (#88): elenco sorgenti di un acquisitore, GET <url>/camera/sources."""
url = (request.args.get("url") or "").strip()
parsed = urlparse(url)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
return jsonify({"error": True, "detail": _("Indirizzo della camera non valido")}), 400
try:
resp = http_requests.get(f"{url.rstrip('/')}/camera/sources", timeout=5)
except http_requests.RequestException as exc:
return jsonify({
"error": True,
"detail": _("Servizio camera non raggiungibile: %(detail)s", detail=str(exc)),
}), 502
invalid = (jsonify({"error": True, "detail": _("Risposta non valida dal servizio camera")}), 502)
if resp.status_code != 200:
return invalid
try:
sources = resp.json().get("sources")
except (ValueError, AttributeError):
return invalid
if not isinstance(sources, list):
return invalid
# solo i tre campi che la UI usa, sempre stringhe
clean = [
{k: str(s.get(k, "")) for k in ("name", "label", "kind")}
for s in sources if isinstance(s, dict)
]
return jsonify({"sources": clean}), 200
@admin_bp.route("/api/stations", methods=["POST"])
@login_required
@admin_required
+170 -1
View File
@@ -4,7 +4,9 @@ import math
import requests as http_requests
from flask import Blueprint, flash, jsonify, redirect, render_template, request, session, url_for
from functools import wraps
from flask import Blueprint, Response, abort, flash, jsonify, redirect, render_template, request, session, url_for
from flask_babel import gettext as _
from blueprints.auth import login_required, role_required
@@ -483,6 +485,173 @@ def api_upload_vision_recipe(task_id: int):
return jsonify(resp), 200
# ============================================================================
# RICETTA DXF DA CAMERA (#83): stazioni, camera, calibrazione, creazione ricetta
# ============================================================================
def maker_or_admin_required(f):
"""Maker dell'editor oppure admin della pagina stazioni (#83: stesse rotte camera)."""
@wraps(f)
def decorated(*args, **kwargs):
user = session.get("user", {})
if not (user.get("is_admin") or "Maker" in user.get("roles", [])):
abort(403)
return f(*args, **kwargs)
return decorated
def _stations_for_maker() -> list[dict]:
"""Stazioni visibili al chiamante.
GET /api/stations e' solo admin: un Maker non admin ripiega sulla stazione
corrente (by-code, aperta a tutti), cosi' l'editor funziona comunque.
"""
resp = api_client.get("/api/stations")
if isinstance(resp, list):
return resp
if Config.STATION_CODE:
one = api_client.get_station_by_code(Config.STATION_CODE)
if isinstance(one, dict) and not one.get("error"):
return [one]
return []
def _camera_of_station(station_id: int) -> tuple[str, str] | None:
"""(camera_url, camera_source) della stazione con questo id, None se senza camera."""
for st in _stations_for_maker():
if st.get("id") == station_id:
if st.get("camera_url") and st.get("camera_source"):
return str(st["camera_url"]), str(st["camera_source"])
return None
return None
@maker_bp.route("/api/stations", methods=["GET"])
@login_required
@maker_or_admin_required
def api_list_stations_for_maker():
"""Elenco stazioni con camera per il pannello «Crea ricetta da DXF»."""
out = []
for st in _stations_for_maker():
if st.get("camera_url") and st.get("camera_source"):
out.append({
"id": st.get("id"), "code": st.get("code"), "name": st.get("name"),
"camera_url": st.get("camera_url"),
})
return jsonify(out), 200
# Copia minima della logica di measure.api_vision_grab, ma per una stazione scelta per id
@maker_bp.route("/api/stations/<int:station_id>/camera/grab", methods=["GET", "POST"])
@login_required
@maker_or_admin_required
def api_station_camera_grab(station_id: int):
"""Proxy: un frame dalla camera della stazione, risposta PNG."""
camera = _camera_of_station(station_id)
if camera is None:
return jsonify({
"error": True,
"detail": _("Camera non configurata per questa stazione"),
}), 409
camera_url, camera_source = camera
try:
resp = http_requests.post(
f"{camera_url.rstrip('/')}/camera/grab",
json={"source": camera_source, "trigger": "continuo", "timeout_ms": 5000},
timeout=15,
)
except http_requests.RequestException as exc:
return jsonify({
"error": True,
"detail": _("Servizio camera non raggiungibile: %(detail)s", detail=str(exc)),
}), 502
if resp.status_code != 200:
detail = ""
try:
body = resp.json()
detail = body.get("detail", "") if isinstance(body, dict) else ""
except ValueError:
detail = (resp.text or "")[:200]
return jsonify({
"error": True,
"detail": _("Acquisizione non riuscita (%(status)s): %(detail)s",
status=resp.status_code, detail=detail),
}), 502
return Response(resp.content, content_type="image/png")
@maker_bp.route("/api/stations/<int:station_id>/camera/calibration", methods=["GET"])
@login_required
@maker_or_admin_required
def api_station_camera_calibration(station_id: int):
"""Proxy: calibrazione della camera letta dall'acquisitore (C1).
Risponde sempre 200 con `state`: calibrated | uncalibrated | unreachable,
cosi' la UI mostra lo stato senza trattare «non calibrata» come errore.
409 solo se la stazione non ha camera.
"""
camera = _camera_of_station(station_id)
if camera is None:
return jsonify({
"error": True,
"detail": _("Camera non configurata per questa stazione"),
}), 409
camera_url, camera_source = camera
base = camera_url.rstrip("/")
link = f"{base}/acquisizione" # pagina di acquisizione dell'acquisitore (#88; era /calibrazione, C4)
try:
resp = http_requests.get(
f"{base}/camera/calibration",
params={"source": camera_source},
timeout=10,
)
except http_requests.RequestException as exc:
return jsonify({"state": "unreachable", "detail": str(exc), "calibration_url": link}), 200
if resp.status_code == 404:
return jsonify({"state": "uncalibrated", "calibration_url": link}), 200
if resp.status_code != 200:
return jsonify({
"state": "unreachable", "calibration_url": link,
"detail": _("Risposta %(status)s dall'acquisitore", status=resp.status_code),
}), 200
try:
calibration = resp.json()
except ValueError:
return jsonify({"state": "unreachable", "calibration_url": link,
"detail": _("Risposta non valida dall'acquisitore")}), 200
return jsonify({"state": "calibrated", "calibration": calibration, "calibration_url": link}), 200
@maker_bp.route("/api/vision/tasks/<int:task_id>/recipe/dxf-compare", methods=["POST"])
@login_required
@role_required("Maker")
def api_dxf_compare_recipe(task_id: int):
"""Proxy: crea (o prova, save=false) la ricetta DXF compare da camera (B3)."""
image = request.files.get("image")
dxf = request.files.get("dxf")
if image is None or not image.filename or dxf is None or not dxf.filename:
return jsonify({"error": True, "detail": _("Servono l'immagine e il file DXF")}), 400
# Solo i campi del contratto B3; i vuoti non si inoltrano (il backend ha i default)
data = {}
for key in ("station_id", "px_per_mm", "band_mm", "min_points_pct", "polarity", "save"):
value = request.form.get(key)
if value not in (None, ""):
data[key] = value
resp = api_client.post(
f"/api/vision/tasks/{task_id}/recipe/dxf-compare",
data=data,
files={
"image": (image.filename, image.read(), image.mimetype or "application/octet-stream"),
"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"])
@login_required
def api_get_file(file_path: str):
@@ -0,0 +1,181 @@
// «Crea ricetta da DXF» dell'editor task (#83): da un'immagine della camera e un DXF
// genera la ricetta dxf_compare (POST /maker/api/vision/tasks/<id>/recipe/dxf-compare).
// «Prova» (save=false) mostra overlay e quattro valori, «Salva ricetta» (save=true)
// la scrive e avvisa il componente visionRecipe con l'evento 'dxf-recipe-saved'.
// Uso: x-data="dxfRecipeWizard(task.id, csrfFn, msgs)". Dipende da Alpine.js e station_calibration.js.
function dxfRecipeWizard(taskId, csrf, msgs) {
// Colori overlay per ruolo, uguali a vision_execute.js (reference, roi, found)
const ROLE_COLORS = { reference: '#2563eb', found: '#16a34a', roi: '#f59e0b' };
const VALUE_KEYS = ['dxf.points_in_pct', 'dxf.distance_avg', 'dxf.distance_min', 'dxf.distance_max'];
return {
taskId: taskId,
msgs: msgs || {},
open: false,
stations: [],
stationId: '',
calState: '', // '' | calibrated | uncalibrated | unreachable
calLabel: '',
calUrl: '',
imageFile: null, // immagine caricata o acquisita
imageName: '',
imageSrc: '',
dxfFile: null,
bandMm: 0.1,
minPointsPct: 95,
dark: true, // spuntato = pezzo scuro su fondo chiaro (controluce)
busy: false,
grabbing: false,
error: '',
notice: '',
result: null, // corpo B3 {result, saved, recipe_dir, outputs}
async toggle() {
this.open = !this.open;
if (this.open && !this.stations.length) await this.loadStations();
},
async loadStations() {
try {
const resp = await fetch('/maker/api/stations');
if (resp.ok) this.stations = await resp.json();
} catch (e) { console.error('dxfRecipeWizard stations', e); }
// Una sola stazione con camera: preselezionata
if (this.stations.length === 1) {
this.stationId = String(this.stations[0].id);
await this.onStation();
}
},
// Cambio stazione: rilegge lo stato di calibrazione dall'acquisitore
async onStation() {
this.calState = ''; this.calLabel = ''; this.calUrl = '';
this.result = null;
if (!this.stationId) return;
const r = await fetchStationCalibration(this.stationId);
this.calState = r.state || 'unreachable';
this.calUrl = r.calibration_url || '';
if (this.calState === 'calibrated') this.calLabel = formatCalibration(r.calibration || {});
},
get calibrated() { return this.calState === 'calibrated'; },
get canRun() {
return !!(this.stationId && this.calibrated && this.imageFile && this.dxfFile) && !this.busy;
},
_setImage(file) {
if (this.imageSrc) URL.revokeObjectURL(this.imageSrc);
this.imageFile = file;
this.imageName = file.name || '';
this.imageSrc = URL.createObjectURL(file);
this.result = null;
},
onImage(ev) {
const f = ev.target.files && ev.target.files[0];
if (f) this._setImage(f);
},
onDxf(ev) { this.dxfFile = (ev.target.files && ev.target.files[0]) || null; this.result = null; },
// Frame dalla camera della stazione scelta
async grab() {
if (!this.stationId || this.grabbing) return;
this.grabbing = true;
this.error = '';
try {
const resp = await fetch(`/maker/api/stations/${this.stationId}/camera/grab`, {
method: 'POST', headers: { 'X-CSRFToken': this._csrf() },
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
this.error = body.detail || this.msgs.requestFailed || 'Error';
} else {
const blob = await resp.blob();
this._setImage(new File([blob], 'camera.png', { type: blob.type || 'image/png' }));
}
} catch (e) {
this.error = this.msgs.requestFailed || String(e);
}
this.grabbing = false;
},
_csrf() { return typeof csrf === 'function' ? csrf() : csrf; },
// Il 422 porta `detail` (stringa o lista) oppure `errors`
_errorText(body) {
if (Array.isArray(body.errors) && body.errors.length) return body.errors.map(String).join('; ');
const d = body.detail;
if (Array.isArray(d)) return d.map(x => (typeof x === 'string' ? x : (x.msg || JSON.stringify(x)))).join('; ');
if (d && typeof d === 'object') return JSON.stringify(d);
return d || this.msgs.requestFailed || 'Error';
},
async run(save) {
if (!this.canRun) return;
this.busy = true;
this.error = '';
this.notice = '';
try {
const fd = new FormData();
fd.append('image', this.imageFile, this.imageFile.name || 'capture.png');
fd.append('dxf', this.dxfFile, this.dxfFile.name);
fd.append('station_id', this.stationId);
fd.append('band_mm', String(this.bandMm));
fd.append('min_points_pct', String(this.minPointsPct));
fd.append('polarity', this.dark ? 'dark' : 'light');
fd.append('save', save ? 'true' : 'false');
const resp = await fetch(`/maker/api/vision/tasks/${this.taskId}/recipe/dxf-compare`, {
method: 'POST', headers: { 'X-CSRFToken': this._csrf() }, body: fd,
});
const body = await resp.json().catch(() => ({}));
if (!resp.ok || body.error) {
this.error = this._errorText(body);
} else {
this.result = body;
this.$nextTick(() => this.drawOverlay());
if (save && body.saved) {
this.notice = this.msgs.saved || 'Saved';
// Il componente visionRecipe ricarica cartella, file e uscite delle quote
this.$dispatch('dxf-recipe-saved', { taskId: this.taskId });
}
}
} catch (e) {
this.error = this.msgs.requestFailed || String(e);
}
this.busy = false;
},
// Le quattro uscite del confronto, nell'ordine fisso
get values() {
const out = (this.result && this.result.result && this.result.result.outputs) || {};
return VALUE_KEYS.map(k => ({ key: k, value: out[k] }));
},
fmt(v) {
return (typeof v === 'number') ? v.toLocaleString(document.documentElement.lang || 'it',
{ maximumFractionDigits: 3 }) : '-';
},
// Come vision_execute.js: canvas della dimensione dell'immagine, coordinate in pixel immagine
drawOverlay() {
const img = this.$refs.img, cv = this.$refs.overlay;
if (!img || !cv || !img.naturalWidth) return;
cv.width = img.naturalWidth;
cv.height = img.naturalHeight;
const g = cv.getContext('2d');
g.clearRect(0, 0, cv.width, cv.height);
g.lineWidth = Math.max(2, cv.width / 400);
g.lineJoin = 'round';
const shapes = (this.result && this.result.result && this.result.result.overlay) || [];
for (const item of shapes) {
const rows = item.rows || [], cols = item.cols || [];
if (!rows.length || rows.length !== cols.length) continue;
g.strokeStyle = ROLE_COLORS[item.role] || '#ef4444';
g.beginPath();
g.moveTo(cols[0], rows[0]);
for (let i = 1; i < rows.length; i++) g.lineTo(cols[i], rows[i]);
g.stroke();
}
},
};
}
@@ -0,0 +1,50 @@
// Stato di calibrazione della camera di una stazione (#83).
// La calibrazione vive nell'acquisitore (camera_service): qui si legge solo lo stato
// tramite il proxy Flask e si offre il link alla sua pagina «Calibra».
// Uso: x-data="stationCalibrationCell(station, msgs)" (msgs: stringhe tradotte). Dipende da Alpine.js.
// Formatta px/mm e data nella lingua della pagina (es. «20,03 px/mm · 23/10/2026»)
function formatCalibration(cal) {
const lang = document.documentElement.lang || 'it';
let text = new Intl.NumberFormat(lang, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
.format(Number(cal.px_per_mm)) + ' px/mm';
if (cal.calibrated_at) {
const d = new Date(cal.calibrated_at);
if (!isNaN(d)) text += ' · ' + d.toLocaleDateString(lang);
}
return text;
}
// Legge lo stato dal proxy: ritorna {state, calibration, calibration_url, detail}
async function fetchStationCalibration(stationId) {
try {
const resp = await fetch(`/maker/api/stations/${stationId}/camera/calibration`);
const body = await resp.json().catch(() => ({}));
if (!resp.ok) return { state: 'unreachable', detail: body.detail || '' };
return body;
} catch (e) {
return { state: 'unreachable', detail: String(e) };
}
}
function stationCalibrationCell(station, msgs) {
return {
hasCamera: !!(station.camera_url && station.camera_source),
cameraUrl: station.camera_url || '',
state: '', // '' = in lettura | calibrated | uncalibrated | unreachable
label: '',
calibrationUrl: '',
msgs: msgs || {},
async init() {
if (!this.hasCamera) return;
const r = await fetchStationCalibration(station.id);
this.state = r.state || 'unreachable';
// Link «Acquisizione» (#88, era «Calibra»): dal proxy, altrimenti costruito dall'indirizzo della camera
this.calibrationUrl = r.calibration_url || (this.cameraUrl.replace(/\/+$/, '') + '/acquisizione');
if (this.state === 'calibrated') this.label = formatCalibration(r.calibration || {});
else if (this.state === 'uncalibrated') this.label = this.msgs.uncalibrated || 'not calibrated';
else this.label = this.msgs.unreachable || 'camera service unreachable';
},
};
}
@@ -50,6 +50,7 @@
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Nome') }}</th>
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider hidden md:table-cell">{{ _('Postazione') }}</th>
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider hidden lg:table-cell">{{ _('Ricette collegate') }}</th>
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider hidden md:table-cell">{{ _('Calibrazione') }}</th>
<th class="text-center px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Stato') }}</th>
<th class="text-right px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Azioni') }}</th>
</tr>
@@ -81,6 +82,25 @@
</div>
</template>
</td>
{# Calibrazione (#83): stato letto dall'acquisitore della camera, link alla sua pagina «Calibra» #}
<td class="px-4 py-3 hidden md:table-cell" @click.stop
x-data="stationCalibrationCell(station, {{ {'uncalibrated': _('non calibrata'), 'unreachable': _('acquisitore non raggiungibile')}|tojson_attr }})">
<template x-if="!hasCamera">
<span class="text-xs italic text-[var(--text-secondary)]">{{ _('Nessuna camera') }}</span>
</template>
<template x-if="hasCamera">
<div class="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
<span :class="state === 'calibrated' ? 'text-emerald-700 dark:text-emerald-300'
: (state === 'uncalibrated' || state === 'unreachable') ? 'text-amber-700 dark:text-amber-300'
: 'text-[var(--text-secondary)]'">
<span x-show="state === 'calibrated'">{{ _('Calibrazione') }}: </span><span x-text="state ? label : '...'"></span>
</span>
<a :href="calibrationUrl" target="_blank" rel="noopener"
x-show="calibrationUrl"
class="font-medium text-primary hover:underline">{{ _('Acquisizione') }}</a> {# #88: era «Calibra» #}
</div>
</template>
</td>
<td class="px-4 py-3 text-center">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium"
:class="station.active
@@ -218,21 +238,42 @@
{# Camera della stazione (#72): servizio camera (vs-gui) e sorgente da cui prendere il frame #}
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Camera (URL)') }}</label>
<input type="text" x-model="form.camera_url"
<input type="text" x-model="form.camera_url" @change="loadCameraSources()"
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
placeholder="host:8765"
maxlength="500">
<p class="mt-1 text-xs text-[var(--text-secondary)]">{{ _('Vuoto = nessuna camera. Indirizzo del servizio camera, con http o https, senza / finale') }}</p>
</div>
{# Sorgente camera (#88): testo libero con suggerimenti letti dall'acquisitore (datalist) #}
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Sorgente camera') }}</label>
<input type="text" x-model="form.camera_source"
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
placeholder="camera:folder"
maxlength="200">
<p class="mt-1 text-xs text-[var(--text-secondary)]">{{ _('Es. camera:<seriale> oppure camera:folder') }}</p>
<div class="flex gap-2">
<input type="text" x-model="form.camera_source" list="cameraSourcesList"
class="flex-1 min-w-0 px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
placeholder="camera:folder"
maxlength="200">
<datalist id="cameraSourcesList">
<template x-for="s in cameraSources" :key="s.name">
<option :value="s.name" :label="s.label"></option>
</template>
</datalist>
<button type="button" @click="loadCameraSources()"
:disabled="!(form.camera_url || '').trim() || cameraSourcesLoading"
class="px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)] text-sm font-medium
text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors whitespace-nowrap">
{{ _('Leggi sorgenti') }}
</button>
</div>
<p class="mt-1 text-xs"
:class="cameraSourcesError ? 'text-red-600 dark:text-red-400' : 'text-[var(--text-secondary)]'"
x-text="cameraSourcesStatus || {{ _('Es. camera:<seriale> oppure camera:folder')|tojson_attr }}"></p>
<p x-show="cameraSourceMissing" class="mt-1 text-xs text-amber-700 dark:text-amber-300">{{ _("Sorgente non presente nell'acquisitore") }}</p>
<a x-show="(form.camera_url || '').trim()"
:href="(form.camera_url || '').trim().replace(/\/+$/, '') + '/acquisizione'"
target="_blank" rel="noopener"
class="mt-1 inline-block text-xs font-medium text-primary hover:underline">{{ _('Apri pagina acquisizione') }}</a>
</div>
<div>
@@ -459,6 +500,7 @@
{% endblock %}
{% block extra_js %}
<script src="{{ url_for('static', filename='js/station_calibration.js') }}"></script>
<script>
function stationManagement(initialStations, initialRecipes) {
return {
@@ -488,6 +530,12 @@ function stationManagement(initialStations, initialRecipes) {
camera_source: '', // #72
active: true,
},
// #88: sorgenti lette dall'acquisitore per il datalist della sorgente camera
cameraSources: [],
cameraSourcesLoaded: false,
cameraSourcesLoading: false,
cameraSourcesStatus: '',
cameraSourcesError: false,
get csrfToken() {
const meta = document.querySelector('meta[name=csrf-token]');
@@ -535,11 +583,46 @@ function stationManagement(initialStations, initialRecipes) {
return '{{ _("Nessuna ricetta assegnata") }}';
},
// #88: legge le sorgenti dall'acquisitore (proxy admin) e riempie il datalist
async loadCameraSources() {
const url = (this.form.camera_url || '').trim();
this.cameraSources = [];
this.cameraSourcesLoaded = false;
this.cameraSourcesError = false;
if (!url) { this.cameraSourcesStatus = ''; return; }
this.cameraSourcesLoading = true;
this.cameraSourcesStatus = '{{ _("Lettura...") }}';
try {
const resp = await fetch('/admin/api/camera/sources?url=' + encodeURIComponent(url));
const result = await resp.json();
if (!resp.ok) {
this.cameraSourcesError = true;
this.cameraSourcesStatus = result.detail || '{{ _("Risposta non valida dal servizio camera") }}';
return;
}
this.cameraSources = result.sources || [];
this.cameraSourcesLoaded = true;
this.cameraSourcesStatus = '{{ _("%(n)s sorgenti trovate", n="__N__") }}'.replace('__N__', this.cameraSources.length);
} catch (e) {
this.cameraSourcesError = true;
this.cameraSourcesStatus = '{{ _("Risposta non valida dal servizio camera") }}';
} finally {
this.cameraSourcesLoading = false;
}
},
// #88: avviso se la sorgente scritta non e' fra quelle lette
get cameraSourceMissing() {
const src = (this.form.camera_source || '').trim();
return this.cameraSourcesLoaded && src !== '' && !this.cameraSources.some(s => s.name === src);
},
openCreateModal() {
this.isEditing = false;
this.editingId = null;
this.errorMsg = '';
this.form = { code: '', name: '', location: '', notes: '', vision_worker_url: '', camera_url: '', camera_source: '', active: true };
this.loadCameraSources(); // #88: svuota lo stato delle sorgenti
this.showModal = true;
},
@@ -557,6 +640,7 @@ function stationManagement(initialStations, initialRecipes) {
camera_source: station.camera_source || '', // #72
active: station.active,
};
this.loadCameraSources(); // #88: se la camera e' valorizzata legge subito le sorgenti
this.showModal = true;
},
@@ -550,7 +550,8 @@
<!-- ========== Ricetta visione (#46): solo camera_measure / dxf_compare ========== -->
<template x-if="task.task_type === 'camera_measure' || task.task_type === 'dxf_compare'">
<div class="p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]"
x-data="visionRecipe(task.id, () => csrfToken(), {{ {'genericError': _('Errore durante il caricamento della ricetta')}|tojson_attr }})">
x-data="visionRecipe(task.id, () => csrfToken(), {{ {'genericError': _('Errore durante il caricamento della ricetta')}|tojson_attr }})"
@dxf-recipe-saved="init()">{# #83: dopo «Salva ricetta» da DXF si rilegge la ricetta #}
<p class="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wide mb-2">
{{ _('Ricetta visione') }}
</p>
@@ -593,6 +594,93 @@
<ul x-show="errors.length" class="mt-3 text-xs text-red-500 list-disc pl-5">
<template x-for="(e, i) in errors" :key="i"><li x-text="e"></li></template>
</ul>
<!-- ========== Crea ricetta da DXF (#83): solo dxf_compare ========== -->
<template x-if="task.task_type === 'dxf_compare'">
<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 }})">
<button type="button" class="btn btn-secondary text-xs" @click="toggle()">
{{ _('Crea ricetta da DXF') }}
</button>
<div x-show="open" x-cloak class="mt-3 space-y-3">
<!-- Stazione -->
<div>
<label class="tmf-label text-xs">{{ _('Stazione') }}</label>
<select class="tmf-input text-xs" x-model="stationId" @change="onStation()">
<option value="">{{ _('Scegli la stazione') }}</option>
<template x-for="st in stations" :key="st.id">
<option :value="String(st.id)" x-text="st.code + ' - ' + st.name"></option>
</template>
</select>
<p x-show="!stations.length" class="mt-1 text-xs text-[var(--text-muted)]">{{ _('Nessuna stazione con camera configurata') }}</p>
<p x-show="stationId && calibrated" class="mt-1 text-xs text-emerald-600">
{{ _('Calibrazione') }}: <span x-text="calLabel"></span>
</p>
<p x-show="stationId && calState && !calibrated" class="mt-1 text-xs text-amber-600">
<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"
class="font-medium text-primary hover:underline">{{ _('Calibra') }}</a>
<span class="text-[var(--text-muted)]">({{ _('oppure vedi la pagina Stazioni') }})</span>
</p>
</div>
<!-- Immagine: camera o file -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="tmf-label text-xs">{{ _('Immagine del pezzo') }}</label>
<div class="flex flex-wrap items-center gap-2">
<button type="button" class="btn btn-secondary text-xs" @click="grab()"
:disabled="!stationId || grabbing">{{ _('Acquisisci dalla camera') }}</button>
<label class="btn btn-secondary text-xs cursor-pointer">
{{ _('Carica immagine') }}
<input type="file" accept="image/*" class="hidden" @change="onImage($event)">
</label>
</div>
<p class="mt-1 text-xs text-[var(--text-muted)]" x-text="imageName"></p>
</div>
<div>
<label class="tmf-label text-xs">{{ _('Disegno (.dxf)') }}</label>
<input type="file" accept=".dxf" class="tmf-input text-xs" @change="onDxf($event)">
</div>
</div>
<!-- Pochi parametri -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="tmf-label text-xs">{{ _('Banda ± mm') }}</label>
<input type="number" step="0.01" min="0.001" class="tmf-input text-xs" x-model.number="bandMm">
</div>
<div>
<label class="tmf-label text-xs">{{ _('Soglia punti in banda %%') }}</label>
<input type="number" step="1" min="1" max="100" class="tmf-input text-xs" x-model.number="minPointsPct">
</div>
</div>
<label class="flex items-center gap-2 text-xs">
<input type="checkbox" x-model="dark">
{{ _('Pezzo scuro su fondo chiaro (controluce)') }}
</label>
<!-- Azioni -->
<div class="flex justify-end gap-2">
<button type="button" class="btn btn-secondary text-xs" @click="run(false)"
:disabled="!canRun">{{ _('Prova') }}</button>
<button type="button" class="btn btn-primary text-xs" @click="run(true)"
:disabled="!canRun || !result">{{ _('Salva ricetta') }}</button>
</div>
<p x-show="error" class="text-xs text-red-500" x-text="error"></p>
<p x-show="notice" class="text-xs text-emerald-600" x-text="notice"></p>
<!-- Anteprima con overlay e quattro valori -->
<div x-show="imageSrc" class="relative">
<img x-ref="img" :src="imageSrc" alt="" class="block w-full rounded border border-[var(--border-color)]"
@load="drawOverlay()">
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full pointer-events-none"></canvas>
</div>
<table x-show="result" class="tmf-table text-xs">
<tbody>
<template x-for="v in values" :key="v.key">
<tr><td class="font-mono" x-text="v.key"></td><td class="text-right" x-text="fmt(v.value)"></td></tr>
</template>
</tbody>
</table>
</div>
</div>
</template>
</div>
</template>
@@ -1245,6 +1333,8 @@
{# Bold and line breaks in descriptions: the same rules the server renders with. #}
<script src="{{ url_for('static', filename='js/rich-text.js') }}"></script>
<script src="{{ url_for('static', filename='js/vision_recipe.js') }}"></script>
<script src="{{ url_for('static', filename='js/station_calibration.js') }}"></script>
<script src="{{ url_for('static', filename='js/dxf_recipe_wizard.js') }}"></script>
<script src="{{ url_for('static', filename='vendor/pdf-3.11.174.min.js') }}"></script>
<script>if(typeof pdfjsLib!=='undefined')pdfjsLib.GlobalWorkerOptions.workerSrc='{{ url_for('static', filename='vendor/pdf.worker-3.11.174.min.js') }}';</script>
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
@@ -254,3 +254,74 @@ def test_create_station_proxy_forwards_the_camera_fields(logged_in_client, mock_
resp = logged_in_client.post("/admin/api/stations", json=body)
assert resp.status_code == 201
mock_admin_api.post.assert_called_once_with("/api/stations", data=body)
# --- Sorgenti camera lette dall'acquisitore (#88) ---
def _fake_sources_response(status=200, payload=None):
resp = MagicMock()
resp.status_code = status
resp.json.return_value = payload
return resp
def test_camera_sources_proxy_keeps_only_the_three_fields(logged_in_client, mock_admin_api):
payload = {"sources": [
{"name": "camera:folder", "label": "Cartella", "kind": "folder", "extra": 1},
{"name": "camera:123", "label": "Basler", "kind": "gige"},
]}
with patch("blueprints.admin.http_requests.get",
return_value=_fake_sources_response(200, payload)) as get:
resp = logged_in_client.get("/admin/api/camera/sources?url=http://acq:8765/")
assert resp.status_code == 200
assert resp.get_json()["sources"][0] == {
"name": "camera:folder", "label": "Cartella", "kind": "folder"}
assert len(resp.get_json()["sources"]) == 2
assert get.call_args.args[0] == "http://acq:8765/camera/sources"
assert get.call_args.kwargs["timeout"] == 5
@pytest.mark.parametrize("url", ["ftp://x", "", "http://", "acq:8765"])
def test_camera_sources_proxy_rejects_invalid_url(logged_in_client, mock_admin_api, url):
with patch("blueprints.admin.http_requests.get") as get:
resp = logged_in_client.get("/admin/api/camera/sources", query_string={"url": url})
assert resp.status_code == 400
assert resp.get_json()["error"] is True
get.assert_not_called()
def test_camera_sources_proxy_unreachable_is_502(logged_in_client, mock_admin_api):
import requests
with patch("blueprints.admin.http_requests.get",
side_effect=requests.ConnectionError("rifiutata")):
resp = logged_in_client.get("/admin/api/camera/sources?url=http://acq:8765")
assert resp.status_code == 502
assert "rifiutata" in resp.get_json()["detail"]
@pytest.mark.parametrize("status,payload", [(200, {"altro": 1}), (200, {"sources": "x"}), (500, {"sources": []})])
def test_camera_sources_proxy_invalid_response_is_502(logged_in_client, mock_admin_api, status, payload):
with patch("blueprints.admin.http_requests.get",
return_value=_fake_sources_response(status, payload)):
resp = logged_in_client.get("/admin/api/camera/sources?url=http://acq:8765")
assert resp.status_code == 502
def test_camera_sources_proxy_non_admin_is_redirected(client, mock_admin_api):
with client.session_transaction() as sess:
sess["api_key"] = "test-key"
sess["user"] = {"id": 2, "username": "operator", "roles": ["MeasurementTec"],
"is_admin": False, "active": True}
sess["user_id"] = 2
with patch("blueprints.admin.http_requests.get") as get:
resp = client.get("/admin/api/camera/sources?url=http://acq:8765")
assert resp.status_code in (301, 302)
get.assert_not_called()
def test_station_form_has_the_camera_sources_picker(logged_in_client, mock_admin_api):
mock_admin_api.get.return_value = []
html = logged_in_client.get("/admin/stations").get_data(as_text=True)
assert "cameraSourcesList" in html
assert "loadCameraSources" in html
assert "/acquisizione" in html
@@ -0,0 +1,232 @@
"""gitea:Adriano/TieMeasureFlow#83 - ricetta DXF compare da camera: rotte Flask nuove.
api_client e requests sono finti: backend (B3) e acquisitore (C1) rispondono
come da contratto del piano.
"""
import io
from unittest.mock import MagicMock, patch
import requests
from tests.conftest import api_get_router
STATION = {
"id": 4, "code": "ST-4", "name": "Banco",
"camera_url": "http://acq:8765/", "camera_source": "camera:folder",
}
NO_CAMERA = {"id": 5, "code": "ST-5", "name": "Senza", "camera_url": None, "camera_source": None}
PNG = b"\x89PNG\r\n\x1a\n fake"
CAL = {
"source": "camera:folder", "px_per_mm": 20.03, "mm_per_px": 0.0499, "rms_px": 0.2,
"n_corners": 54, "cols": 9, "rows": 6, "square_mm": 5.0,
"image_width": 2448, "image_height": 2048, "scale_spread_pct": 0.3,
"calibrated_at": "2026-10-23T10:00:00",
}
def _fake(status=200, content=PNG, json_body=None):
resp = MagicMock(status_code=status, content=content, text=content.decode("latin-1"))
if json_body is None:
resp.json.side_effect = ValueError("no json")
else:
resp.json.return_value = json_body
return resp
def _stations(mock_api_client, stations=(STATION, NO_CAMERA)):
mock_api_client.get.side_effect = api_get_router({"/api/stations": list(stations)})
class TestStationList:
def test_only_stations_with_camera(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
resp = logged_in_client.get("/maker/api/stations")
assert resp.status_code == 200
assert [s["id"] for s in resp.get_json()] == [4]
def test_non_admin_list_falls_back_to_current_station(
self, logged_in_client, mock_api_client, monkeypatch):
# /api/stations e' solo admin: il Maker vede la stazione corrente (by-code)
monkeypatch.setattr("blueprints.maker.Config.STATION_CODE", "ST-4")
mock_api_client.get.side_effect = api_get_router(
{"/api/stations": {"error": True, "status_code": 403, "detail": "no"}})
mock_api_client.get_station_by_code.return_value = STATION
resp = logged_in_client.get("/maker/api/stations")
assert [s["id"] for s in resp.get_json()] == [4]
mock_api_client.get_station_by_code.assert_called_once_with("ST-4")
def test_requires_maker_or_admin(self, logged_in_client, mock_api_client):
with logged_in_client.session_transaction() as sess:
sess["user"] = {**sess["user"], "roles": ["MeasurementTec"], "is_admin": False}
assert logged_in_client.get("/maker/api/stations").status_code == 403
class TestStationCameraGrab:
def test_png_pass_through(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
with patch("blueprints.maker.http_requests.post", return_value=_fake()) as post:
resp = logged_in_client.post("/maker/api/stations/4/camera/grab")
assert resp.status_code == 200
assert resp.mimetype == "image/png"
assert resp.data == PNG
args, kwargs = post.call_args
assert args[0] == "http://acq:8765/camera/grab" # slash finale tolto
assert kwargs["json"] == {"source": "camera:folder", "trigger": "continuo", "timeout_ms": 5000}
def test_get_also_works(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
with patch("blueprints.maker.http_requests.post", return_value=_fake()):
assert logged_in_client.get("/maker/api/stations/4/camera/grab").status_code == 200
def test_station_without_camera_is_409(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
with patch("blueprints.maker.http_requests.post") as post:
resp = logged_in_client.post("/maker/api/stations/5/camera/grab")
assert resp.status_code == 409
post.assert_not_called()
def test_unknown_station_is_409(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
assert logged_in_client.post("/maker/api/stations/99/camera/grab").status_code == 409
def test_service_down_is_502(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
with patch("blueprints.maker.http_requests.post",
side_effect=requests.ConnectionError("refused")):
resp = logged_in_client.post("/maker/api/stations/4/camera/grab")
assert resp.status_code == 502
assert resp.get_json()["error"]
def test_service_503_is_502_with_detail(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
fake = _fake(503, b"{}", {"detail": "camera occupata"})
with patch("blueprints.maker.http_requests.post", return_value=fake):
resp = logged_in_client.post("/maker/api/stations/4/camera/grab")
assert resp.status_code == 502
assert "camera occupata" in resp.get_json()["detail"]
class TestStationCameraCalibration:
def _get(self, client, fake=None, exc=None):
with patch("blueprints.maker.http_requests.get", return_value=fake,
side_effect=exc) as get:
resp = client.get("/maker/api/stations/4/camera/calibration")
return resp, get
def test_calibrated(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
resp, get = self._get(logged_in_client, _fake(200, b"{}", CAL))
body = resp.get_json()
assert resp.status_code == 200
assert body["state"] == "calibrated"
assert body["calibration"]["px_per_mm"] == 20.03
assert body["calibration_url"] == "http://acq:8765/acquisizione"
args, kwargs = get.call_args
assert args[0] == "http://acq:8765/camera/calibration"
assert kwargs["params"] == {"source": "camera:folder"}
def test_404_is_uncalibrated(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
resp, _ = self._get(logged_in_client, _fake(404, b"{}", {"detail": "no"}))
assert resp.status_code == 200
assert resp.get_json()["state"] == "uncalibrated"
assert resp.get_json()["calibration_url"] == "http://acq:8765/acquisizione"
def test_unreachable(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
resp, _ = self._get(logged_in_client, exc=requests.ConnectionError("down"))
assert resp.status_code == 200
assert resp.get_json()["state"] == "unreachable"
def test_server_error_is_unreachable(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
resp, _ = self._get(logged_in_client, _fake(500, b"boom"))
assert resp.get_json()["state"] == "unreachable"
def test_station_without_camera_is_409(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
with patch("blueprints.maker.http_requests.get") as get:
resp = logged_in_client.get("/maker/api/stations/5/camera/calibration")
assert resp.status_code == 409
get.assert_not_called()
class TestDxfCompareProxy:
URL = "/maker/api/vision/tasks/7/recipe/dxf-compare"
OK = {
"result": {"outputs": {"dxf.points_in_pct": 99.1}, "overlay": [], "outcome": "pass"},
"saved": False, "recipe_dir": None, "outputs": None,
}
def _post(self, client, **form):
data = {"image": (io.BytesIO(PNG), "p.png"), "dxf": (io.BytesIO(b"0\nEOF"), "d.dxf"), **form}
return client.post(self.URL, data=data, content_type="multipart/form-data")
def test_forwards_files_and_fields(self, logged_in_client, mock_api_client):
mock_api_client.post.return_value = self.OK
resp = self._post(logged_in_client, station_id="4", band_mm="0.1",
min_points_pct="95", polarity="dark", save="false", px_per_mm="")
assert resp.status_code == 200
assert resp.get_json()["result"]["outputs"]["dxf.points_in_pct"] == 99.1
args, kwargs = mock_api_client.post.call_args
assert args[0] == "/api/vision/tasks/7/recipe/dxf-compare"
# campi del contratto B3; px_per_mm vuoto non si inoltra
assert kwargs["data"] == {
"station_id": "4", "band_mm": "0.1", "min_points_pct": "95",
"polarity": "dark", "save": "false",
}
assert set(kwargs["files"]) == {"image", "dxf"}
assert kwargs["files"]["image"][1] == PNG
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"}
resp = self._post(logged_in_client, save="true")
assert resp.get_json()["saved"] is True
assert mock_api_client.post.call_args.kwargs["data"]["save"] == "true"
def test_missing_files_is_400(self, logged_in_client, mock_api_client):
resp = logged_in_client.post(self.URL, data={"station_id": "4"},
content_type="multipart/form-data")
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": "stazione non calibrata"}
resp = self._post(logged_in_client)
assert resp.status_code == 422
assert resp.get_json()["detail"] == "stazione non calibrata"
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:
def test_stations_page_has_calibration_cell(self, logged_in_client):
with patch("blueprints.admin.api_client") as admin_api:
admin_api.get.side_effect = api_get_router({"/api/stations": [STATION], "/api/recipes": []})
html = logged_in_client.get("/admin/stations").get_data(as_text=True)
assert "js/station_calibration.js" in html
assert "stationCalibrationCell(" in html
# niente piu' pannello di calibrazione nella pagina: solo il link all'acquisitore
assert "calibration-panel" not in html
def _editor(self, client, mock_api_client, task_type):
mock_api_client.get.side_effect = api_get_router({
"/api/recipes/3": {"id": 3, "code": "R", "name": "R"},
"/api/recipes/3/tasks": [{
"id": 11, "title": "T", "task_type": task_type, "subtasks": [],
"file_path": None, "order_index": 1,
}],
})
return client.get("/maker/recipes/3/tasks").get_data(as_text=True)
def test_editor_has_dxf_wizard(self, logged_in_client, mock_api_client):
html = self._editor(logged_in_client, mock_api_client, "dxf_compare")
assert "js/dxf_recipe_wizard.js" in html
assert "dxfRecipeWizard(" in html
assert "@dxf-recipe-saved" in html
assert "Pezzo scuro su fondo chiaro" in html or "Dark part on light" in html
@@ -3359,3 +3359,129 @@ msgstr "Camera service unreachable: %(detail)s"
#, python-format
msgid "Acquisizione non riuscita (%(status)s): %(detail)s"
msgstr "Capture failed (%(status)s): %(detail)s"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
#, python-format
msgid "Risposta %(status)s dall'acquisitore"
msgstr "Response %(status)s from the camera service"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Risposta non valida dall'acquisitore"
msgstr "Invalid response from the camera service"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Servono l'immagine e il file DXF"
msgstr "Both the image and the DXF file are required"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Calibrazione"
msgstr "Calibration"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "non calibrata"
msgstr "not calibrated"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "acquisitore non raggiungibile"
msgstr "camera service unreachable"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Nessuna camera"
msgstr "No camera"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Calibra"
msgstr "Calibrate"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Ricetta salvata"
msgstr "Recipe saved"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Crea ricetta da DXF"
msgstr "Create recipe from DXF"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Scegli la stazione"
msgstr "Choose the station"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Nessuna stazione con camera configurata"
msgstr "No station with a camera configured"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Stazione non calibrata: calibrala prima di creare la ricetta."
msgstr "Station not calibrated: calibrate it before creating the recipe."
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Acquisitore non raggiungibile: impossibile leggere la calibrazione."
msgstr "Camera service unreachable: cannot read the calibration."
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "oppure vedi la pagina Stazioni"
msgstr "or see the Stations page"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Immagine del pezzo"
msgstr "Part image"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Carica immagine"
msgstr "Upload image"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Disegno (.dxf)"
msgstr "Drawing (.dxf)"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Banda ± mm"
msgstr "Band ± mm"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Soglia punti in banda %%"
msgstr "Points-in-band threshold %%"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Pezzo scuro su fondo chiaro (controluce)"
msgstr "Dark part on light background (backlight)"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Prova"
msgstr "Try"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Salva ricetta"
msgstr "Save recipe"
#: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Acquisizione"
msgstr "Acquisition"
#: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Leggi sorgenti"
msgstr "Read sources"
#: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Lettura..."
msgstr "Reading..."
#: templates/admin/stations.html blueprints/admin.py (#88)
#, python-format
msgid "%(n)s sorgenti trovate"
msgstr "%(n)s sources found"
#: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Sorgente non presente nell'acquisitore"
msgstr "Source not present in the acquisition service"
#: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Apri pagina acquisizione"
msgstr "Open acquisition page"
#: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Indirizzo della camera non valido"
msgstr "Invalid camera address"
#: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Risposta non valida dal servizio camera"
msgstr "Invalid response from the camera service"
@@ -3347,3 +3347,129 @@ msgstr "Servizio camera non raggiungibile: %(detail)s"
#, python-format
msgid "Acquisizione non riuscita (%(status)s): %(detail)s"
msgstr "Acquisizione non riuscita (%(status)s): %(detail)s"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
#, python-format
msgid "Risposta %(status)s dall'acquisitore"
msgstr "Risposta %(status)s dall'acquisitore"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Risposta non valida dall'acquisitore"
msgstr "Risposta non valida dall'acquisitore"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Servono l'immagine e il file DXF"
msgstr "Servono l'immagine e il file DXF"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Calibrazione"
msgstr "Calibrazione"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "non calibrata"
msgstr "non calibrata"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "acquisitore non raggiungibile"
msgstr "acquisitore non raggiungibile"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Nessuna camera"
msgstr "Nessuna camera"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Calibra"
msgstr "Calibra"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Ricetta salvata"
msgstr "Ricetta salvata"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Crea ricetta da DXF"
msgstr "Crea ricetta da DXF"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Scegli la stazione"
msgstr "Scegli la stazione"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Nessuna stazione con camera configurata"
msgstr "Nessuna stazione con camera configurata"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Stazione non calibrata: calibrala prima di creare la ricetta."
msgstr "Stazione non calibrata: calibrala prima di creare la ricetta."
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Acquisitore non raggiungibile: impossibile leggere la calibrazione."
msgstr "Acquisitore non raggiungibile: impossibile leggere la calibrazione."
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "oppure vedi la pagina Stazioni"
msgstr "oppure vedi la pagina Stazioni"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Immagine del pezzo"
msgstr "Immagine del pezzo"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Carica immagine"
msgstr "Carica immagine"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Disegno (.dxf)"
msgstr "Disegno (.dxf)"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Banda ± mm"
msgstr "Banda ± mm"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Soglia punti in banda %%"
msgstr "Soglia punti in banda %%"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Pezzo scuro su fondo chiaro (controluce)"
msgstr "Pezzo scuro su fondo chiaro (controluce)"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Prova"
msgstr "Prova"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Salva ricetta"
msgstr "Salva ricetta"
#: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Acquisizione"
msgstr "Acquisizione"
#: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Leggi sorgenti"
msgstr "Leggi sorgenti"
#: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Lettura..."
msgstr "Lettura..."
#: templates/admin/stations.html blueprints/admin.py (#88)
#, python-format
msgid "%(n)s sorgenti trovate"
msgstr "%(n)s sorgenti trovate"
#: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Sorgente non presente nell'acquisitore"
msgstr "Sorgente non presente nell'acquisitore"
#: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Apri pagina acquisizione"
msgstr "Apri pagina acquisizione"
#: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Indirizzo della camera non valido"
msgstr "Indirizzo della camera non valido"
#: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Risposta non valida dal servizio camera"
msgstr "Risposta non valida dal servizio camera"
+263
View File
@@ -0,0 +1,263 @@
"""Costruttore di ricette DXF compare dal campo (immagine + DXF + scala -> ricetta v2).
Logica pura, niente FastAPI ne' database: il worker (src/vision_worker/main.py)
lo espone come /recipes/dxf-compare, i test lo chiamano direttamente. Tutta la
visione sta in TieVision-Core (visionsuite.*). La calibrazione a scacchiera NON
sta qui: vive nell'acquisitore (scripts/calibrazione.py, #83).
Errori di input (pezzo non trovato, DXF illeggibile, parametri fuori range)
sono `CostruttoreError`: il worker li mappa a 422.
"""
from __future__ import annotations
import base64
import hashlib
import json
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
from src.vision.runner import RunOutcome, run_graph
class CostruttoreError(ValueError):
"""Input che non permette di costruire (422 per il worker), non un guasto."""
# Nomi/id fissi della ricetta (contratto #83 con backend e frontend).
DXF_FILE = "disegno.dxf"
CALIBRATION_FILE = "calibrazione.json"
_POLARITIES = ("dark", "light")
# Pose iniziali del disegno provate (gradi): il verso del pezzo non e' noto.
_OUTPUT_KEYS = ("dxf.points_in_pct", "dxf.distance_avg", "dxf.distance_min", "dxf.distance_max")
_START_ANGLES = (0.0, 90.0, 180.0, 270.0)
def _b64(data: bytes) -> str:
return base64.b64encode(data).decode("ascii")
def _gray_u8(pixels: np.ndarray) -> np.ndarray:
"""Immagine -> grigio uint8 2D (LocateBlob accetta solo uint8)."""
px = np.asarray(pixels)
if px.ndim == 3:
px = px[..., 0] # multicanale: il primo, come fa LocateBlob
if px.dtype != np.uint8:
# float/uint16 -> scala sul massimo, come _to_gray_uint8 di vs-core
m = float(px.max()) if px.size else 1.0
px = np.clip(px.astype(np.float32) / max(m, 1e-9) * 255, 0, 255).astype(np.uint8)
return np.ascontiguousarray(px)
# ------------------------------------------------------------------ W2
def _graph(dxf_sha: str, cal_sha: str, band_mm: float, min_points_pct: float, polarity: str,
reference_pose: dict, anchor: tuple[float, float, float] | None) -> dict:
"""Ricetta v2 fissa: LocateBlob "pezzo" + CompareDxf "dxf" agganciato a `align`.
`anchor` = (riga, colonna, angolo) dell'origine del disegno nell'immagine di
riferimento; None = solo il localizzatore (passo intermedio).
"""
tools: list[dict] = [{
"id": "pezzo", "name": "Pezzo", "kind": "LocateBlob",
"values": {"polarity": polarity, "reference_pose": reference_pose},
"links": {},
}]
if anchor is not None:
row, col, angle = anchor
tools.append({
"id": "dxf", "name": "Confronto DXF", "kind": "CompareDxf",
"values": {
"dxf": "disegno",
"anchor": "align",
"anchor_offset_row_px": float(row),
"anchor_offset_col_px": float(col),
"offset_angle_deg": float(angle),
"band_plus": float(band_mm),
"band_minus": float(band_mm),
"nominal_unit": "mm",
# tolleranza di points_in_pct: nominale = limite, plus = 100 - limite
"points_in_pct_nominal": float(min_points_pct),
"points_in_pct_plus": float(100.0 - min_points_pct),
"points_in_pct_minus": 0.0,
},
"links": {"align": {"tool": "pezzo", "property": "poses"}},
})
return {
"schema_version": 2,
"name": "Confronto DXF",
# sha256 obbligatorio per vs-task (verifica i file della cartella ricetta)
"meta": {"dxf": {"disegno": {"path": DXF_FILE, "sha256": dxf_sha}},
"calibration": {"path": CALIBRATION_FILE, "sha256": cal_sha}},
"tools": tools,
}
def _locate_reference(gray: np.ndarray, polarity: str) -> dict:
"""Posa del pezzo sull'immagine di riferimento (LocateBlob, soglia Otsu)."""
from visionsuite.task import Task, load_tools
load_tools()
graph = _graph('', '', 1.0, 95.0, polarity, {"row": 0.0, "col": 0.0, "angle_deg": 0.0}, None)
task = Task.from_dict(graph)
task.run(context={"image": Image(gray)})
pezzo = task["Pezzo"]
if not pezzo.found_poses:
raise CostruttoreError(
"pezzo non trovato nell'immagine di riferimento "
f"(polarity={polarity}): controllare luce, sfondo e polarita'"
)
return pezzo.found_poses[0].to_dict()
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
from visionsuite.task.pose import Pose
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")
if len(measured) < 3:
raise CostruttoreError("pezzo non trovato: nessun bordo nell'immagine")
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 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
def _run(gray, graph, px_per_mm, folder) -> RunOutcome:
return run_graph(gray, graph, calibration={"px_per_mm": px_per_mm}, recipe_dir=folder)
def build_dxf_compare(
gray,
dxf_bytes: bytes,
px_per_mm: float,
band_mm: float = 0.1,
min_points_pct: float = 95.0,
polarity: str = "dark",
) -> dict:
"""Ricetta DXF compare dall'immagine di riferimento + disegno + scala.
Ritorna {graph, files, outputs, overlay, outcome}. `files` sono i due file
della cartella ricetta (b64): disegno.dxf e calibrazione.json.
"""
if not (px_per_mm > 0 and math.isfinite(px_per_mm)):
raise CostruttoreError("px_per_mm deve essere > 0")
if not (band_mm > 0 and math.isfinite(band_mm)):
raise CostruttoreError("band_mm deve essere > 0")
if not (0 < min_points_pct <= 100):
raise CostruttoreError("min_points_pct deve stare in (0, 100]")
if polarity not in _POLARITIES:
raise CostruttoreError(f"polarity deve essere una di {list(_POLARITIES)}")
if not dxf_bytes:
raise CostruttoreError("DXF vuoto")
frame = _gray_u8(gray)
calibration_bytes = json.dumps({"px_per_mm": px_per_mm}).encode("utf-8")
dxf_sha = hashlib.sha256(dxf_bytes).hexdigest()
cal_sha = hashlib.sha256(calibration_bytes).hexdigest()
with tempfile.TemporaryDirectory() as tmp:
folder = Path(tmp)
dxf_path = folder / DXF_FILE
dxf_path.write_bytes(dxf_bytes)
(folder / CALIBRATION_FILE).write_bytes(calibration_bytes)
# DXF leggibile? (ezdxf solleva su file corrotto/non DXF)
from visionsuite.io.dxf import read_xld_dxf
try:
drawing = list(read_xld_dxf(str(dxf_path), sampling_distance=0.1,
scale=px_per_mm, flip_y=True))
except Exception as exc: # noqa: BLE001 - qualunque errore di lettura = DXF illeggibile
raise CostruttoreError(f"DXF illeggibile: {type(exc).__name__}: {exc}") from exc
if not drawing:
raise CostruttoreError("DXF illeggibile: nessuna entita' geometrica")
reference_pose = _locate_reference(frame, polarity)
anchors = _candidate_anchors(frame, dxf_path, px_per_mm, polarity) # #86
if not anchors:
raise CostruttoreError("disegno non sovrapponibile al pezzo (ICP fallito)")
# Verita' = eseguire davvero la ricetta sul riferimento: vince il maggior points_in_pct
best: tuple[float, dict, RunOutcome] | None = None
for anchor in anchors:
graph = _graph(dxf_sha, cal_sha, band_mm, min_points_pct, polarity, reference_pose, anchor)
outcome = _run(frame, graph, px_per_mm, folder)
pct = outcome.outputs.get("dxf.points_in_pct")
if pct is not None and (best is None or pct > best[0]):
best = (pct, graph, outcome)
if best is None:
raise CostruttoreError(
"disegno non sovrapponibile al pezzo: il confronto non ha prodotto punti"
)
_, graph, outcome = best
# solo le quattro uscite del contratto (n_points resta fuori)
outputs = {k: v for k, v in outcome.outputs.items() if k in _OUTPUT_KEYS}
return {
"graph": graph,
"files": {DXF_FILE: _b64(dxf_bytes), CALIBRATION_FILE: _b64(calibration_bytes)},
"outputs": outputs,
"overlay": outcome.overlay,
"outcome": outcome.outcome,
}
+150
View File
@@ -0,0 +1,150 @@
"""Costruttore di ricette dal campo: immagine + DXF + scala -> ricetta v2."""
import pytest
# Come test_runner.py: l'albero vision richiede Python >=3.13; su 3.11 e' skipped.
pytest.importorskip(
"visionsuite.task",
reason=(
"the vision tree requires Python >=3.13; run it with "
"`uv run --python 3.13 --extra vision --extra dev pytest src/vision/tests`"
),
)
import base64
import json
from pathlib import Path
import cv2
import numpy as np
from src.vision.costruttori import (
CALIBRATION_FILE,
DXF_FILE,
CostruttoreError,
_candidate_anchors,
_maschera_pezzo,
build_dxf_compare,
)
from src.vision.runner import run_graph
SAMPLE_DXF = (
Path(__file__).resolve().parents[3]
/ "vendor/visionsuite/packages/vs-dxf/tests/fixtures/sample.dxf"
)
DXF_WIDTH_MM, DXF_HEIGHT_MM, DXF_HOLE_R_MM = 200.0, 100.0, 10.0
PX_PER_MM = 4.0
def a_scene(angle_deg: float = 0.0, shift_px: tuple[float, float] = (0.0, 0.0),
margin_mm: float = 60.0) -> np.ndarray:
"""Il pezzo del sample.dxf (scuro su bianco) a 4 px/mm, ruotato e traslato."""
w = int((DXF_WIDTH_MM + 2 * margin_mm) * PX_PER_MM)
h = int((DXF_HEIGHT_MM + 2 * margin_mm) * PX_PER_MM)
img = np.full((h, w), 255, np.uint8)
cx, cy = w // 2, h // 2
hw, hh = int(DXF_WIDTH_MM * PX_PER_MM / 2), int(DXF_HEIGHT_MM * PX_PER_MM / 2)
cv2.rectangle(img, (cx - hw, cy - hh), (cx + hw, cy + hh), 0, -1)
cv2.circle(img, (cx, cy), int(DXF_HOLE_R_MM * PX_PER_MM), 255, -1)
if angle_deg or any(shift_px):
m = cv2.getRotationMatrix2D((cx, cy), angle_deg, 1.0) # >0 = antiorario a video
m[0, 2] += shift_px[0]
m[1, 2] += shift_px[1]
img = cv2.warpAffine(img, m, (w, h), flags=cv2.INTER_LINEAR, borderValue=255)
return img
# ------------------------------------------------------------------ ricetta DXF
def test_ricetta_dxf_gira_su_riferimento_e_su_scena_ruotata(tmp_path):
dxf = SAMPLE_DXF.read_bytes()
risultato = build_dxf_compare(a_scene(0.0), dxf, px_per_mm=PX_PER_MM, band_mm=2.0)
assert risultato["outputs"]["dxf.points_in_pct"] > 95.0 # sul riferimento
assert set(risultato["outputs"]) == {
"dxf.points_in_pct", "dxf.distance_avg", "dxf.distance_min", "dxf.distance_max"}
assert set(risultato["files"]) == {DXF_FILE, CALIBRATION_FILE}
assert json.loads(base64.b64decode(risultato["files"][CALIBRATION_FILE])) == {
"px_per_mm": PX_PER_MM}
assert risultato["overlay"] and risultato["outcome"] is not None
graph = risultato["graph"]
assert graph["schema_version"] == 2
# path come da contratto; lo sha256 lo esige vs-task per ogni file in meta
assert graph["meta"]["dxf"]["disegno"]["path"] == "disegno.dxf"
assert graph["meta"]["calibration"]["path"] == "calibrazione.json"
assert len(graph["meta"]["dxf"]["disegno"]["sha256"]) == 64
pezzo, dxf_tool = graph["tools"]
assert (pezzo["id"], pezzo["kind"], pezzo["name"]) == ("pezzo", "LocateBlob", "Pezzo")
assert (dxf_tool["id"], dxf_tool["kind"], dxf_tool["name"]) == ("dxf", "CompareDxf", "Confronto DXF")
assert dxf_tool["links"] == {"align": {"tool": "pezzo", "property": "poses"}}
# La cartella ricetta come la scriverebbe il backend, poi la scena ruotata di 5 gradi
for nome, b64 in risultato["files"].items():
(tmp_path / nome).write_bytes(base64.b64decode(b64))
ruotata = a_scene(5.0, shift_px=(30.0, 20.0))
esito = run_graph(ruotata, graph, calibration={"px_per_mm": PX_PER_MM}, recipe_dir=tmp_path)
assert not esito.failures, esito.failures
assert esito.outputs["dxf.points_in_pct"] > 95.0
def test_ricetta_dxf_con_riferimento_gia_ruotato(tmp_path):
# il riferimento non e' a 0 gradi: la ricetta si costruisce lo stesso
risultato = build_dxf_compare(a_scene(20.0, shift_px=(-15.0, 10.0)),
SAMPLE_DXF.read_bytes(), PX_PER_MM, band_mm=2.0)
assert risultato["outputs"]["dxf.points_in_pct"] > 95.0
def test_immagine_senza_pezzo_solleva():
bianca = np.full((600, 800), 255, np.uint8)
with pytest.raises(CostruttoreError, match="pezzo"):
build_dxf_compare(bianca, SAMPLE_DXF.read_bytes(), PX_PER_MM)
def test_dxf_illeggibile_solleva():
with pytest.raises(CostruttoreError, match="DXF"):
build_dxf_compare(a_scene(), b"questo non e' un dxf", PX_PER_MM)
@pytest.mark.parametrize("kwargs", [
{"px_per_mm": 0.0}, {"px_per_mm": PX_PER_MM, "band_mm": 0.0},
{"px_per_mm": PX_PER_MM, "min_points_pct": 0.0},
{"px_per_mm": PX_PER_MM, "min_points_pct": 101.0},
{"px_per_mm": PX_PER_MM, "polarity": "grigio"},
])
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)
+65
View File
@@ -17,6 +17,7 @@ import numpy as np
from fastapi import FastAPI, File, Form, HTTPException, UploadFile, status
from PIL import Image, UnidentifiedImageError
from src.vision.costruttori import CostruttoreError, build_dxf_compare # #83
from src.vision.runner import (
GraphBuildError,
RunOutcome,
@@ -223,3 +224,67 @@ async def validate(
validate_graph, parsed_graph, resolved_dir
)
return {"ok": ok, "errors": errors, "outputs": outputs}
def _decode_and_build_dxf(content: bytes, dxf: bytes, px_per_mm: float,
band_mm: float, min_points_pct: float,
polarity: str) -> dict:
"""#83: meta' sincrona di /recipes/dxf-compare (decodifica + costruzione),
in un thread come `_decode_and_run`."""
try:
frame = np.array(Image.open(io.BytesIO(content)).convert("L"))
except (UnidentifiedImageError, OSError) as exc:
raise _ImageDecodeError(str(exc)) from exc
return build_dxf_compare(frame, dxf, px_per_mm, band_mm, min_points_pct, polarity)
@app.post("/recipes/dxf-compare")
async def recipes_dxf_compare(
image: UploadFile = File(...),
dxf: UploadFile = File(...),
px_per_mm: float = Form(...),
band_mm: float = Form(0.1),
min_points_pct: float = Form(95.0),
polarity: str = Form("dark"),
) -> dict:
"""#83: costruisce una ricetta DXF compare dall'immagine di riferimento, il
disegno e la scala. Stessi semaforo, timeout e mappa errori di /run."""
content = await image.read()
dxf_bytes = await dxf.read()
if _run_slots.locked():
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"worker busy: already running {_MAX_CONCURRENCY} job(s)",
)
async with _run_slots:
try:
return await asyncio.wait_for(
asyncio.to_thread(
_decode_and_build_dxf, content, dxf_bytes, px_per_mm,
band_mm, min_points_pct, polarity,
),
timeout=_RUN_TIMEOUT_S,
)
except asyncio.TimeoutError as exc:
raise HTTPException(
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
detail=f"vision run exceeded {_RUN_TIMEOUT_S}s",
) from exc
except _ImageDecodeError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"image is not decodable: {exc}",
) from exc
except (CostruttoreError, GraphBuildError) as exc:
# pezzo non trovato, DXF illeggibile, parametri invalidi: input, non guasto
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"recipe build failed: {type(exc).__name__}: {exc}",
) from exc
@@ -0,0 +1,82 @@
"""POST /recipes/dxf-compare del worker (#83): forma della risposta ed errori."""
import pytest
# Come test_worker.py: serve l'albero vision (Python >=3.13), altrimenti skipped.
pytest.importorskip(
"visionsuite.task",
reason="the vision tree requires Python >=3.13; see test_worker.py",
)
import base64
import io
import time
import numpy as np
from httpx import ASGITransport, AsyncClient
from PIL import Image
from src.vision.tests.test_costruttori import PX_PER_MM, SAMPLE_DXF, a_scene
from src.vision_worker.main import app
def _png(array: np.ndarray) -> bytes:
buffer = io.BytesIO()
Image.fromarray(array).save(buffer, format="PNG")
return buffer.getvalue()
async def _post(image: bytes, dxf: bytes, **data):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://worker") as client:
return await client.post(
"/recipes/dxf-compare",
files={"image": ("riferimento.png", image, "image/png"),
"dxf": ("disegno.dxf", dxf, "application/octet-stream")},
data={"px_per_mm": str(PX_PER_MM), "band_mm": "2.0", **data},
)
@pytest.mark.asyncio
async def test_dxf_compare_200_con_le_chiavi_del_contratto():
inizio = time.monotonic()
response = await _post(_png(a_scene(0.0)), SAMPLE_DXF.read_bytes())
durata = time.monotonic() - inizio
assert response.status_code == 200, response.text
body = response.json()
assert {"graph", "files", "outputs", "overlay", "outcome"} <= set(body)
assert set(body["files"]) == {"disegno.dxf", "calibrazione.json"}
assert base64.b64decode(body["files"]["disegno.dxf"]) == SAMPLE_DXF.read_bytes()
assert set(body["outputs"]) == {
"dxf.points_in_pct", "dxf.distance_avg", "dxf.distance_min", "dxf.distance_max"}
assert body["outputs"]["dxf.points_in_pct"] > 95.0
assert body["graph"]["schema_version"] == 2
print(f"\nW2 sulla scena di prova: {durata:.2f}s") # riportato nel rapporto
@pytest.mark.asyncio
async def test_dxf_compare_immagine_senza_pezzo_e_422():
bianca = np.full((600, 800), 255, np.uint8)
response = await _post(_png(bianca), SAMPLE_DXF.read_bytes())
assert response.status_code == 422
assert "pezzo" in response.json()["detail"]
@pytest.mark.asyncio
async def test_dxf_compare_dxf_illeggibile_e_422():
response = await _post(_png(a_scene(0.0)), b"non e' un dxf")
assert response.status_code == 422
assert "DXF" in response.json()["detail"]
@pytest.mark.asyncio
async def test_dxf_compare_immagine_non_decodificabile_e_422():
response = await _post(b"non e' un'immagine", SAMPLE_DXF.read_bytes())
assert response.status_code == 422
assert "not decodable" in response.json()["detail"].lower()
@pytest.mark.asyncio
async def test_dxf_compare_parametro_fuori_range_e_422():
response = await _post(_png(a_scene(0.0)), SAMPLE_DXF.read_bytes(), polarity="grigio")
assert response.status_code == 422