Compare commits

...

10 Commits

Author SHA1 Message Date
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
Adriano ae2353f7f8 Pacchetto Windows: registra gli strumenti senza entry point e controlla il catalogo nel build (#82)
rilascio-windows / setup-windows (push) Successful in 32m30s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VMYrdz6uBbuwbaQ9MqDmMc
2026-10-19 23:03:48 +02:00
Adriano d652891c1c Merge lavoro/daitarn-tmf-81 (TieMeasureFlow#81)
rilascio-windows / setup-windows (push) Successful in 34m11s
2026-10-17 14:25:56 +02:00
Adriano 0b22643d69 Merge lavoro/daitarn-tmf-80 (TieMeasureFlow#80) 2026-10-17 14:25:56 +02:00
Adriano 15e31e2b8a Merge lavoro/daitarn-tmf-79 (TieMeasureFlow#79) 2026-10-17 14:25:55 +02:00
Adriano f8f5f248ff setup: init-db e reset-db stampano head in un thread (TieMeasureFlow#80)
_stamp_head esegue env.py di alembic, che fa asyncio.run() e dentro il loop
di FastAPI dava RuntimeError (500). Ora gira con asyncio.to_thread. Nuovo
test_setup_stamp.py senza monkeypatch di _stamp_head, su file SQLite.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 14:25:24 +02:00
Adriano f33415df6a Test frontend: niente piu' dipendenza dall'ordine sulla stazione (TieMeasureFlow#79)
test_measure.py non ricarica piu' config/blueprints.measure (monkeypatch.setattr,
annullato al teardown); TestTaskComplete e test_recipe_rules_ui.py fissano la
stazione esplicitamente con una fixture autouse.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 14:13:15 +02:00
Adriano 62b5e412ca static del frontend sempre LF (.gitattributes): impronte di VERSIONS.md e pacchetto Windows identici (TieMeasureFlow#81)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:10:39 +02:00
18 changed files with 1446 additions and 50 deletions
+3
View File
@@ -0,0 +1,3 @@
# TieMeasureFlow#81: static del frontend sempre con LF, anche con core.autocrlf=true su Windows:
# le impronte sha256 di static/vendor/VERSIONS.md (test_offline.py) e il pacchetto Windows le vogliono identiche.
src/frontend/flask_app/static/** text=auto eol=lf
+3
View File
@@ -73,3 +73,6 @@ docker-compose.override.yml
# Subagent-driven development scratch: ledger, briefs, review packages # Subagent-driven development scratch: ledger, briefs, review packages
.superpowers/ .superpowers/
# Generato da packaging/windows/build.ps1 (TieMeasureFlow#82): moduli-strumento dagli entry point
packaging/windows/_strumenti.py
+1
View File
@@ -347,6 +347,7 @@ Variabili d'ambiente in `.env` (copiare da `.env.example`):
- ⚠️ Alembic carica `env.py` e `versions\` dal disco, quindi Nuitka non può compilarli: `build.ps1` li copia come soli `.pyc` (`compileall -b`) e `avvio.py` esegue `upgrade head` con `sourceless=true`. È l'unica eccezione al «nessun `.py`/`.pyc`»: il controllo finale di `build.ps1` ammette `.pyc` solo sotto `src\backend\migrations`. `env.py` chiama `fileConfig`, che azzera i gestori del log: `avvio.py` li ricrea dopo le migrazioni. - ⚠️ Alembic carica `env.py` e `versions\` dal disco, quindi Nuitka non può compilarli: `build.ps1` li copia come soli `.pyc` (`compileall -b`) e `avvio.py` esegue `upgrade head` con `sourceless=true`. È l'unica eccezione al «nessun `.py`/`.pyc`»: il controllo finale di `build.ps1` ammette `.pyc` solo sotto `src\backend\migrations`. `env.py` chiama `fileConfig`, che azzera i gestori del log: `avvio.py` li ricrea dopo le migrazioni.
- ⚠️ `numba` è escluso dal pacchetto (`--nofollow-import-to=numba`): le funzioni compilate da Nuitka non hanno bytecode e numba non può farne il JIT. Core e pm2d hanno il ripiego in Python puro (`HAS_NUMBA=False`): funziona ma il matching è più lento. Da misurare sul TC bench. - ⚠️ `numba` è escluso dal pacchetto (`--nofollow-import-to=numba`): le funzioni compilate da Nuitka non hanno bytecode e numba non può farne il JIT. Core e pm2d hanno il ripiego in Python puro (`HAS_NUMBA=False`): funziona ma il matching è più lento. Da misurare sul TC bench.
- ⚠️ L'exe è senza console: `sys.stdout`/`sys.stderr` possono essere `None` (`avvio.py` li sostituisce con il device nullo), uvicorn gira con `log_config=None` e il log va su file (`...\log\tiemeasureflow.log`). I server uvicorn girano in thread (nessun signal handler) e waitress si crea con `create_server`. - ⚠️ L'exe è senza console: `sys.stdout`/`sys.stderr` possono essere `None` (`avvio.py` li sostituisce con il device nullo), uvicorn gira con `log_config=None` e il log va su file (`...\log\tiemeasureflow.log`). I server uvicorn girano in thread (nessun signal handler) e waitress si crea con `create_server`.
- ⚠️ Strumenti di visione (TieMeasureFlow#82): nel binario non ci sono i metadati di `vs-tools`, quindi `load_tools()` (entry point `visionsuite.task.tools`) non trova nulla e ogni ricetta fallisce con tipo sconosciuto. `build.ps1` genera `packaging/windows/_strumenti.py` (`MODULI`, gitignorato, letto dal venv del build), `avvio.py` `registra_strumenti()` lo importa prima dei server, e prima di Inno Setup `build.ps1` lancia l'exe con `--controlla-strumenti` (senza licenza né server; JSON in `...\log\controllo-strumenti.json`, exit 3 se il catalogo è vuoto o un modulo non si importa).
- ⚠️ `visionsuite` è un namespace condiviso da più distribuzioni: `build.ps1` elenca i sottopacchetti dagli `__path__` e i metadati con `--include-distribution-metadata`. Un nuovo pacchetto di Core si prende da solo se è in `[tool.uv.sources]` e nel gruppo. - ⚠️ `visionsuite` è un namespace condiviso da più distribuzioni: `build.ps1` elenca i sottopacchetti dagli `__path__` e i metadati con `--include-distribution-metadata`. Un nuovo pacchetto di Core si prende da solo se è in `[tool.uv.sources]` e nel gruppo.
- ⚠️ `tailwind.css` non è nel repo: `build.ps1` lo genera con `npx tailwindcss@3.4.19` (stessa versione di `Dockerfile.frontend`); serve Internet solo a chi costruisce. Il setup finale non ha bisogno di Internet. - ⚠️ `tailwind.css` non è nel repo: `build.ps1` lo genera con `npx tailwindcss@3.4.19` (stessa versione di `Dockerfile.frontend`); serve Internet solo a chi costruisce. Il setup finale non ha bisogno di Internet.
- ⚠️ Chrome o Edge servono a kaleido per i grafici dei report: `avvio.py` imposta `BROWSER_PATH` al primo trovato. Le DLL di WeasyPrint le scarica `packaging/windows/pdf/prendi-gtk.ps1` (piano #78) in `gtk\` accanto all'exe. - ⚠️ Chrome o Edge servono a kaleido per i grafici dei report: `avvio.py` imposta `BROWSER_PATH` al primo trovato. Le DLL di WeasyPrint le scarica `packaging/windows/pdf/prendi-gtk.ps1` (piano #78) in `gtk\` accanto all'exe.
+59
View File
@@ -17,6 +17,11 @@ Come si usa: e' l'exe compilato con Nuitka (build.ps1); da sorgente, dalla radic
con l'ambiente 3.13 del pacchetto: `python packaging/windows/avvio.py [--impronta] [--host H]`. con l'ambiente 3.13 del pacchetto: `python packaging/windows/avvio.py [--impronta] [--host H]`.
Opzioni di servizio: `--senza-finestre` (niente MessageBox, solo stderr: serve alle prove Opzioni di servizio: `--senza-finestre` (niente MessageBox, solo stderr: serve alle prove
automatiche; NON salta il controllo di licenza) e `--senza-browser`. automatiche; NON salta il controllo di licenza) e `--senza-browser`.
`--controlla-strumenti` (TieMeasureFlow#82): NON richiede la licenza, non avvia server, migrazioni
ne' database; registra gli strumenti, scrive su stdout e in
%PROGRAMDATA%\\Tielogic\\TieMeasureFlow\\log\\controllo-strumenti.json un JSON
{strumenti, kind, falliti, unavailable} ed esce con 0 (strumenti > 0 e nessun fallito) o 3.
La usa build.ps1 prima di Inno Setup.
Dipende da: visionsuite.license (vs-license, contratto licenza v1), uvicorn, waitress, alembic. Dipende da: visionsuite.license (vs-license, contratto licenza v1), uvicorn, waitress, alembic.
@@ -25,11 +30,15 @@ Dipende da: visionsuite.license (vs-license, contratto licenza v1), uvicorn, wai
⚠️ Le variabili d'ambiente vanno impostate PRIMA di importare backend e frontend (leggono ⚠️ Le variabili d'ambiente vanno impostate PRIMA di importare backend e frontend (leggono
la configurazione all'import): per questo gli import pesanti stanno dentro le funzioni. la configurazione all'import): per questo gli import pesanti stanno dentro le funzioni.
⚠️ La cartella ProgramData la rende scrivibile agli utenti l'installer (.iss). ⚠️ La cartella ProgramData la rende scrivibile agli utenti l'installer (.iss).
⚠️ Nel binario Nuitka mancano i metadati di vs-tools, quindi `load_tools()` (che cerca gli
strumenti come entry point) non ne trova nessuno: `build.ps1` genera `_strumenti.py`
(MODULI, dagli entry point del venv) e `registra_strumenti()` li importa per nome (#82).
""" """
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import ctypes import ctypes
import importlib
import json import json
import logging import logging
import os import os
@@ -264,6 +273,50 @@ def _prepara_log(dati: Path | None = None) -> None:
pass pass
# ---------------------------------------------------------------- strumenti di visione
def registra_strumenti() -> list[str]:
"""Registra gli strumenti di Core; torna i moduli che non si sono importati (#82).
Nel pacchetto Nuitka gli entry point non esistono: `load_tools()` non trova nulla e
l'elenco dei moduli arriva da `_strumenti.py`, generato da build.ps1. Da sorgente
`_strumenti` non c'e' e bastano gli entry point. Il worker di visione gira in un thread
di questo processo: registrare una volta sola qui vale per tutti."""
from visionsuite.task.registry import load_tools
load_tools()
try:
from _strumenti import MODULI # generato da build.ps1, accanto ad avvio.py
except ImportError:
MODULI = () # da sorgente: gli entry point di load_tools() bastano
falliti: list[str] = []
for modulo in MODULI:
try:
importlib.import_module(modulo) # eseguire il modulo registra i suoi @tool
except Exception: # un modulo rotto non ferma l'app: si logga e si raccoglie
log.error("strumenti %s non importati", modulo, exc_info=True)
falliti.append(modulo)
return falliti
def controlla_strumenti() -> int:
"""Senza licenza, server ne' DB: scrive il JSON del catalogo strumenti. Exit 0 se completo, 3 altrimenti."""
from visionsuite.task.graph import registry
from visionsuite.task.registry import unavailable
falliti = registra_strumenti()
kind = sorted(registry())
esito = {"strumenti": len(kind), "kind": kind, "falliti": falliti, "unavailable": unavailable()}
riga = json.dumps(esito, ensure_ascii=False)
if sys.stdout is not None:
print(riga)
try:
f = cartella_dati() / "log" / "controllo-strumenti.json"
f.parent.mkdir(parents=True, exist_ok=True)
f.write_text(riga + "\n", encoding="utf-8") # l'exe e' senza console: il file e' l'unica uscita sicura
except OSError as e:
log.warning("controllo-strumenti.json non scritto: %s", e)
return 0 if kind and not falliti else 3
# ---------------------------------------------------------------- rete # ---------------------------------------------------------------- rete
def _porta_aperta(porta: int) -> bool: def _porta_aperta(porta: int) -> bool:
@@ -402,6 +455,8 @@ def main(argv=None) -> int:
p.add_argument("--host", default="127.0.0.1", help="indirizzo di ascolto dell'interfaccia web (default 127.0.0.1)") p.add_argument("--host", default="127.0.0.1", help="indirizzo di ascolto dell'interfaccia web (default 127.0.0.1)")
p.add_argument("--senza-finestre", action="store_true", help="niente MessageBox (prove automatiche)") p.add_argument("--senza-finestre", action="store_true", help="niente MessageBox (prove automatiche)")
p.add_argument("--senza-browser", action="store_true", help="non aprire il browser (prove automatiche)") p.add_argument("--senza-browser", action="store_true", help="non aprire il browser (prove automatiche)")
p.add_argument("--controlla-strumenti", action="store_true",
help="registra gli strumenti, scrive il JSON ed esce (senza licenza ne' server; usata da build.ps1)")
args = p.parse_args(argv) args = p.parse_args(argv)
_prepara_log() _prepara_log()
@@ -415,6 +470,9 @@ def main(argv=None) -> int:
messaggio(f"Impronta di questo computer:\n{impronta}", errore=False, senza_finestre=args.senza_finestre) messaggio(f"Impronta di questo computer:\n{impronta}", errore=False, senza_finestre=args.senza_finestre)
return 0 return 0
if args.controlla_strumenti:
return controlla_strumenti() # prima della licenza: la build non ha una licenza
if not controlla_licenza(args.senza_finestre): if not controlla_licenza(args.senza_finestre):
return 1 return 1
@@ -449,6 +507,7 @@ def main(argv=None) -> int:
_prepara_log(dati) # alembic ha riconfigurato il logging _prepara_log(dati) # alembic ha riconfigurato il logging
log.info("migrazioni applicate (primo avvio: %s)", primo_avvio) log.info("migrazioni applicate (primo avvio: %s)", primo_avvio)
registra_strumenti() # prima del server: il worker di visione legge registry() gia' popolato (#82)
thread = avvia_server(args.host, dati) thread = avvia_server(args.host, dati)
if not args.senza_browser: if not args.senza_browser:
# database senza utenti: si apre la pagina di setup del backend (SETUP_PASSWORD in config.env) # database senza utenti: si apre la pagina di setup del backend (SETUP_PASSWORD in config.env)
+37 -2
View File
@@ -26,6 +26,10 @@ segue da solo, quindi i sottopacchetti si elencano uno per uno dagli __path__ in
⚠️ numba e' escluso di proposito: le funzioni compilate da Nuitka non hanno bytecode e numba ⚠️ numba e' escluso di proposito: le funzioni compilate da Nuitka non hanno bytecode e numba
non puo' farne il JIT. Core e pm2d hanno gia' il ripiego in Python puro (HAS_NUMBA=False): non puo' farne il JIT. Core e pm2d hanno gia' il ripiego in Python puro (HAS_NUMBA=False):
funziona ma e' piu' lento nel matching. funziona ma e' piu' lento nel matching.
⚠️ Gli strumenti di vs-tools si registrano solo come entry point (visionsuite.task.tools) e i
metadati di vs-tools non stanno nel binario (TieMeasureFlow#82): il build genera
packaging\windows\_strumenti.py (MODULI, gitignorato) che avvio.py importa, e prima di
Inno Setup esegue l'exe con --controlla-strumenti: se il catalogo e' vuoto il build fallisce.
⚠️ Il frontend Flask non e' un pacchetto (`from config import Config`, `blueprints`, `services`): ⚠️ Il frontend Flask non e' un pacchetto (`from config import Config`, `blueprints`, `services`):
si compila mettendo flask_app e scripts su PYTHONPATH, come fa avvio.py in sviluppo. si compila mettendo flask_app e scripts su PYTHONPATH, come fa avvio.py in sviluppo.
#> #>
@@ -85,8 +89,9 @@ try {
$pacchetti = & $py -c "import visionsuite, pkgutil; print('\n'.join('visionsuite.' + m.name for m in pkgutil.iter_modules(visionsuite.__path__) if m.ispkg))" $pacchetti = & $py -c "import visionsuite, pkgutil; print('\n'.join('visionsuite.' + m.name for m in pkgutil.iter_modules(visionsuite.__path__) if m.ispkg))"
if ($LASTEXITCODE -ne 0 -or -not $pacchetti) { throw "Elenco dei pacchetti visionsuite non riuscito" } if ($LASTEXITCODE -ne 0 -or -not $pacchetti) { throw "Elenco dei pacchetti visionsuite non riuscito" }
$includi = @($pacchetti -split "\r?\n" | Where-Object { $_ } | ForEach-Object { "--include-package=$_" }) $includi = @($pacchetti -split "\r?\n" | Where-Object { $_ } | ForEach-Object { "--include-package=$_" })
# Nessun --include-distribution-metadata per vs-*: nulla li legge e Nuitka rifiuta i metadati di una # Nessun --include-distribution-metadata per vs-*: Nuitka rifiuta i metadati di una distribuzione il cui
# distribuzione il cui pacchetto (visionsuite.camera, namespace) non e' di primo livello. # pacchetto (visionsuite.camera, namespace) non e' di primo livello. Gli entry point di vs-tools, che
# load_tools() cercherebbe nei metadati, si rimpiazzano con _strumenti.py (sotto, TieMeasureFlow#82).
Write-Host "Pacchetti visionsuite: $($includi -join ' ')" Write-Host "Pacchetti visionsuite: $($includi -join ' ')"
# Flag Nuitka del piano PDF (WeasyPrint, kaleido, pyphen, plotly, tzdata...): le righe "--include-..." # Flag Nuitka del piano PDF (WeasyPrint, kaleido, pyphen, plotly, tzdata...): le righe "--include-..."
@@ -103,6 +108,25 @@ if (Test-Path $leggimi) {
# Nuitka cerca i moduli non-pacchetto (config, services, blueprints, app, camera_service) su PYTHONPATH # Nuitka cerca i moduli non-pacchetto (config, services, blueprints, app, camera_service) su PYTHONPATH
$env:PYTHONPATH = (@($radice, $flask, (Join-Path $radice 'scripts')) -join ';') $env:PYTHONPATH = (@($radice, $flask, (Join-Path $radice 'scripts')) -join ';')
# TieMeasureFlow#82: i moduli-strumento dagli entry point del venv del build (nel binario non ci sono i
# metadati di vs-tools, quindi load_tools() non li troverebbe). Si usa $py, non `uv run`.
$strumenti = Join-Path $radice 'packaging\windows\_strumenti.py'
$genera = @'
import sys
from importlib.metadata import entry_points
moduli = sorted({ep.value for ep in entry_points(group="visionsuite.task.tools")})
if not moduli:
sys.exit(1)
righe = ["# Generato da build.ps1 (TieMeasureFlow#82): non modificare, non committare.", "MODULI = ("]
righe += [f" {m!r}," for m in moduli] + [")"]
with open(sys.argv[1], "w", encoding="utf-8", newline="") as f:
print(*righe, sep=chr(10), file=f)
print(len(moduli))
'@
$nModuli = $genera | & $py - $strumenti
if ($LASTEXITCODE -ne 0) { throw "Entry point visionsuite.task.tools non trovati nel venv: nessuno strumento da includere" }
Write-Host "Moduli-strumento ($nModuli) in $strumenti"
$out = Join-Path $radice 'build\nuitka' $out = Join-Path $radice 'build\nuitka'
New-Item -ItemType Directory -Force $out | Out-Null New-Item -ItemType Directory -Force $out | Out-Null
$nuitka = @( $nuitka = @(
@@ -201,6 +225,17 @@ $sporchi = Get-ChildItem $sorgente -Recurse -Include *.py, *.pyc -File |
Where-Object { $_.FullName -notlike "$mig\*" -or $_.Extension -eq '.py' } Where-Object { $_.FullName -notlike "$mig\*" -or $_.Extension -eq '.py' }
if ($sporchi) { throw "Il pacchetto contiene sorgenti/bytecode:`n$($sporchi.FullName -join "`n")" } if ($sporchi) { throw "Il pacchetto contiene sorgenti/bytecode:`n$($sporchi.FullName -join "`n")" }
# TieMeasureFlow#82: l'exe deve vedere gli strumenti (--controlla-strumenti non vuole licenza ne' apre server/DB)
$exe = Join-Path $sorgente 'TieMeasureFlow.exe'
$jsonControllo = Join-Path $env:PROGRAMDATA 'Tielogic\TieMeasureFlow\log\controllo-strumenti.json'
Remove-Item $jsonControllo -ErrorAction SilentlyContinue # un JSON vecchio non deve far passare il controllo
# l'exe e' GUI-subsystem: con & non si aspetta, serve Start-Process -Wait
$proc = Start-Process -FilePath $exe -ArgumentList '--controlla-strumenti' -Wait -PassThru
$esito = if (Test-Path $jsonControllo) { Get-Content $jsonControllo -Raw } else { '(controllo-strumenti.json non scritto)' }
if ($proc.ExitCode -ne 0) { throw "Controllo strumenti fallito (codice $($proc.ExitCode)): $esito" }
$nStrumenti = ($esito | ConvertFrom-Json).strumenti
Write-Host "Controllo strumenti: $nStrumenti tipi registrati nell'exe"
if ($SenzaSetup) { Write-Host "Fermo qui (-SenzaSetup): $sorgente"; return } if ($SenzaSetup) { Write-Host "Fermo qui (-SenzaSetup): $sorgente"; return }
$dist = Join-Path $radice 'dist' $dist = Join-Path $radice 'dist'
+76
View File
@@ -13,6 +13,8 @@ import importlib.util
import json import json
import os import os
import sqlite3 import sqlite3
import sys
import types
from datetime import date, timedelta from datetime import date, timedelta
from pathlib import Path from pathlib import Path
@@ -195,3 +197,77 @@ def test_chiavi_extra_di_config_env_passano_ma_non_sovrascrivono_il_contratto(av
avvio.main(["--senza-finestre", "--senza-browser"]) avvio.main(["--senza-finestre", "--senza-browser"])
assert os.environ["STATION_CODE"] == "ST-FIERA" and os.environ["STATION_SWITCH_ENABLED"] == "1" assert os.environ["STATION_CODE"] == "ST-FIERA" and os.environ["STATION_SWITCH_ENABLED"] == "1"
assert os.environ["APP_ENV"] == "production" # le variabili fisse vincono assert os.environ["APP_ENV"] == "production" # le variabili fisse vincono
# --- TieMeasureFlow#82: strumenti nel pacchetto (nel binario non ci sono gli entry point) ---
def _modulo_strumenti(monkeypatch, moduli):
# finto _strumenti.py generato da build.ps1
finto = types.ModuleType("_strumenti")
finto.MODULI = tuple(moduli)
monkeypatch.setitem(sys.modules, "_strumenti", finto)
def test_registra_strumenti_da_sorgente_senza_modulo_generato(avvio, monkeypatch):
# _strumenti.py esiste solo dopo build.ps1: se manca (sorgente) bastano gli entry point
monkeypatch.setitem(sys.modules, "_strumenti", None) # None in sys.modules = ImportError all'import
from visionsuite.task.graph import registry
assert avvio.registra_strumenti() == []
assert len(registry()) > 0
def test_registra_strumenti_modulo_inesistente_non_solleva(avvio, monkeypatch):
_modulo_strumenti(monkeypatch, ("visionsuite.tools.image", "visionsuite.tools.non_esiste"))
assert avvio.registra_strumenti() == ["visionsuite.tools.non_esiste"] # raccolto, non sollevato
def test_registra_strumenti_importa_i_moduli_elencati(avvio, monkeypatch):
# senza entry point (come nell'exe) i moduli di MODULI sono l'unica via: li si importa per nome
_modulo_strumenti(monkeypatch, ("visionsuite.tools.image",))
visti = []
monkeypatch.setattr(avvio.importlib, "import_module", lambda nome: visti.append(nome))
assert avvio.registra_strumenti() == [] and visti == ["visionsuite.tools.image"]
def test_controlla_strumenti_senza_licenza_scrive_json(avvio, monkeypatch, capsys, tmp_path):
monkeypatch.setitem(sys.modules, "_strumenti", None)
# la licenza non deve essere nemmeno chiesta, e server e migrazioni non devono partire
def mai(*a, **k):
raise AssertionError("licenza, server o migrazioni chiamati da --controlla-strumenti")
monkeypatch.setattr(avvio.lic, "verifica", mai)
monkeypatch.setattr(avvio.lic, "cerca_licenza", mai)
monkeypatch.setattr(avvio, "avvia_server", mai)
monkeypatch.setattr(avvio, "esegui_migrazioni", mai)
assert avvio.main(["--controlla-strumenti", "--senza-finestre"]) == 0
dati = json.loads(capsys.readouterr().out)
assert dati["strumenti"] > 0 and dati["strumenti"] == len(dati["kind"])
assert dati["falliti"] == []
scritto = tmp_path / "Tielogic" / "TieMeasureFlow" / "log" / "controllo-strumenti.json"
assert json.loads(scritto.read_text(encoding="utf-8")) == dati
assert avvio.aperti == [] and not (tmp_path / "Tielogic" / "TieMeasureFlow" / "tmf.db").exists() # nessun DB
def test_controlla_strumenti_exit_3_se_un_modulo_fallisce(avvio, monkeypatch, capsys):
_modulo_strumenti(monkeypatch, ("visionsuite.tools.non_esiste",))
assert avvio.main(["--controlla-strumenti"]) == 3
assert json.loads(capsys.readouterr().out)["falliti"] == ["visionsuite.tools.non_esiste"]
def test_controlla_strumenti_exit_3_se_il_catalogo_e_vuoto(avvio, monkeypatch, capsys):
# niente strumenti registrati (es. entry point assenti e _strumenti vuoto): il controllo deve fallire
import visionsuite.task.graph as graph
monkeypatch.setattr(avvio, "registra_strumenti", lambda: [])
monkeypatch.setattr(graph, "registry", lambda: {})
assert avvio.main(["--controlla-strumenti"]) == 3
assert json.loads(capsys.readouterr().out)["strumenti"] == 0
def test_avvio_normale_registra_gli_strumenti_prima_del_server(avvio, tmp_path, monkeypatch):
# main() chiama registra_strumenti() dopo licenza e migrazioni e prima di avvia_server
_scrivi_licenza(tmp_path)
monkeypatch.setattr(avvio, "registra_strumenti", lambda: avvio.chiamate.append("strumenti") or [])
assert avvio.main(["--senza-finestre", "--senza-browser"]) == 0
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
assert avvio.chiamate == ["migrazioni", "strumenti", ("127.0.0.1", dati)]
+144
View File
@@ -0,0 +1,144 @@
"""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).
Uso: importato da scripts/camera_service.py (rotte /camera/calibrate e
/camera/calibration); `calibra_scacchiera(gray, cols, rows, square_mm)` e'
logica pura, senza FastAPI, e si prova da sola.
Dipende da: numpy, opencv, visionsuite (vs-core). Niente altro.
"""
from __future__ import annotations
import base64
import hashlib
import json
import math
import os
import re
import tempfile
from datetime import datetime, timezone
from pathlib import Path
import cv2
import numpy as np
from visionsuite.calibration import calibrate_planar, find_chessboard
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, 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),
"overlay_png": base64.b64encode(png.tobytes()).decode("ascii"),
}
def ora_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
class Archivio:
"""Un JSON per sorgente in `cartella`; nome file dalla sorgente ripulito."""
def __init__(self, cartella: Path | str):
self.cartella = Path(cartella)
def _file(self, sorgente: str) -> Path:
# caratteri sicuri + hash dell'originale: due sorgenti diverse non si pestano
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"
def leggi(self, sorgente: str) -> dict | None:
try:
return json.loads(self._file(sorgente).read_text(encoding="utf-8"))
except (OSError, ValueError):
return None # assente o illeggibile = non calibrata
def salva(self, sorgente: str, dati: dict) -> None:
destinazione = self._file(sorgente)
self.cartella.mkdir(parents=True, exist_ok=True)
# scrittura atomica: file temporaneo nella stessa cartella, poi replace
fd, tmp = tempfile.mkstemp(dir=self.cartella, 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 elimina(self, sorgente: str) -> None:
try:
self._file(sorgente).unlink()
except FileNotFoundError:
pass
+218
View File
@@ -0,0 +1,218 @@
"""Pagina HTML della calibrazione a scacchiera (servita da camera_service su /calibrazione, #83).
Cosa fa: una pagina sola, autonoma e offline (niente CDN, JS e CSS inline), in
italiano. Parla con le rotte dello stesso servizio (/camera/sources,
/camera/calibration, /camera/calibrate).
Dipende da: niente. E' una stringa, cosi' il pacchetto compilato non ha file dati.
"""
PAGINA = """<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Calibrazione camera</title>
<style>
body { font-family: system-ui, sans-serif; margin: 0; background: #f3f4f6; color: #1f2937; }
main { max-width: 960px; margin: 0 auto; padding: 16px; }
h1 { font-size: 1.4rem; margin: 0 0 12px; }
.scheda { background: #fff; border: 1px solid #d1d5db; border-radius: 8px; padding: 14px; margin-bottom: 14px; }
label { display: block; font-size: .85rem; margin-bottom: 4px; color: #4b5563; }
select, input[type=number] { padding: 6px 8px; font-size: 1rem; border: 1px solid #9ca3af; border-radius: 6px; }
.riga { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; }
button { padding: 8px 14px; font-size: 1rem; border: 0; border-radius: 6px; background: #2563eb; color: #fff; cursor: pointer; }
button.secondario { background: #6b7280; }
button.pericolo { background: #b91c1c; }
button:disabled { opacity: .5; cursor: default; }
.messaggio { margin-top: 10px; font-weight: 600; }
.errore { color: #b91c1c; }
.ok { color: #15803d; }
table { border-collapse: collapse; }
td { padding: 3px 14px 3px 0; }
td:first-child { color: #4b5563; }
img { max-width: 100%; border: 1px solid #d1d5db; border-radius: 6px; margin-top: 8px; }
.avviso { color: #92400e; }
</style>
</head>
<body>
<main>
<h1>Calibrazione camera con scacchiera</h1>
<div class="scheda">
<div class="riga">
<div><label for="sorgente">Sorgente</label><select id="sorgente"></select></div>
<div><label for="cols">Angoli interni in orizzontale</label><input id="cols" type="number" min="2" value="9"></div>
<div><label for="rows">Angoli interni in verticale</label><input id="rows" type="number" min="2" value="6"></div>
<div><label for="quadrato">Lato del quadrato (mm)</label><input id="quadrato" type="number" min="0.001" step="any" value="2"></div>
</div>
<p style="font-size:.85rem;color:#4b5563;margin:10px 0 0">
Gli angoli interni sono quelli dove si toccano quattro quadrati: una scacchiera di 10 x 7 quadrati ha 9 x 6 angoli.
Tenere la scacchiera piana e ferma, ben illuminata.</p>
</div>
<div class="scheda">
<div class="riga">
<button id="acquisisci">Acquisisci e calcola</button>
<div><label for="file">oppure un file immagine</label><input id="file" type="file" accept="image/*"></div>
<button id="carica" class="secondario">Carica immagine e calcola</button>
</div>
<div id="messaggio" class="messaggio"></div>
</div>
<div class="scheda" id="risultato" hidden>
<strong>Risultato del calcolo</strong>
<table id="tabella"></table>
<div id="avviso" class="avviso"></div>
<div class="riga" style="margin-top:10px">
<button id="salva">Salva per questa sorgente</button>
<span style="font-size:.85rem;color:#4b5563">Da camera, salvando si acquisisce e si calcola di nuovo: tenere la scacchiera ferma.</span>
</div>
<img id="overlay" alt="Angoli trovati sulla scacchiera">
</div>
<div class="scheda">
<strong>Calibrazione salvata</strong>
<div id="stato">-</div>
<div class="riga" style="margin-top:10px">
<button id="elimina" class="pericolo" disabled>Elimina</button>
</div>
</div>
</main>
<script>
"use strict";
var $ = function (id) { return document.getElementById(id); };
var ultimo = null; // ultimo calcolo, per «Salva»
function messaggio(testo, classe) {
var m = $("messaggio");
m.textContent = testo || "";
m.className = "messaggio " + (classe || "");
}
function numero(v, cifre) { return Number(v).toFixed(cifre); }
function righe(d) {
return [
["Scala", numero(d.px_per_mm, 4) + " px/mm (" + numero(d.mm_per_px, 5) + " mm/px)"],
["Errore di riproiezione (RMS)", numero(d.rms_px, 3) + " px"],
["Variazione di scala nell'area", numero(d.scale_spread_pct, 2) + " %"],
["Angoli trovati", d.n_corners + " (" + d.cols + " x " + d.rows + ")"],
["Lato del quadrato", d.square_mm + " mm"],
["Immagine", d.image_width + " x " + d.image_height + " px"]
];
}
function tabella(el, d) {
el.textContent = "";
righe(d).forEach(function (r) {
var tr = document.createElement("tr");
r.forEach(function (c) {
var td = document.createElement("td");
td.textContent = c;
tr.appendChild(td);
});
el.appendChild(tr);
});
}
async function dettaglio(risposta) {
try { var j = await risposta.json(); return j.detail || JSON.stringify(j); }
catch (e) { return "errore " + risposta.status; }
}
async function caricaSorgenti() {
var sel = $("sorgente");
try {
var r = await fetch("/camera/sources");
var j = await r.json();
(j.sources || []).forEach(function (s) {
var o = document.createElement("option");
o.value = s.name;
o.textContent = s.label || s.name;
sel.appendChild(o);
});
} catch (e) { messaggio("Elenco delle sorgenti non disponibile.", "errore"); }
if (!sel.options.length) messaggio("Nessuna sorgente camera trovata.", "errore");
aggiornaStato();
}
async function aggiornaStato() {
var sorgente = $("sorgente").value;
var stato = $("stato");
$("elimina").disabled = true;
if (!sorgente) { stato.textContent = "-"; return; }
var r = await fetch("/camera/calibration?source=" + encodeURIComponent(sorgente));
if (r.status === 404) { stato.textContent = "Sorgente non calibrata."; return; }
if (!r.ok) { stato.textContent = await dettaglio(r); return; }
var d = await r.json();
stato.textContent = "";
var t = document.createElement("table");
tabella(t, d);
var quando = document.createElement("div");
quando.textContent = "Calibrata il " + d.calibrated_at;
stato.appendChild(t);
stato.appendChild(quando);
$("elimina").disabled = false;
}
function parametri() {
return {
cols: $("cols").value, rows: $("rows").value, quadrato: $("quadrato").value
};
}
async function calcola(conFile, salva) {
var sorgente = $("sorgente").value;
if (!sorgente) { messaggio("Scegliere una sorgente.", "errore"); return; }
var p = parametri();
var f = new FormData();
f.append("source", sorgente);
f.append("cols", p.cols);
f.append("rows", p.rows);
f.append("square_mm", p.quadrato);
f.append("save", salva ? "true" : "false");
if (conFile) {
var file = $("file").files[0];
if (!file) { messaggio("Scegliere un file immagine.", "errore"); return; }
f.append("image", file);
}
messaggio("Calcolo in corso...", "");
["acquisisci", "carica", "salva"].forEach(function (i) { $(i).disabled = true; });
try {
var r = await fetch("/camera/calibrate", { method: "POST", body: f });
if (!r.ok) { messaggio(await dettaglio(r), "errore"); return; }
var d = await r.json();
ultimo = { conFile: conFile, dati: d };
tabella($("tabella"), d);
$("overlay").src = "data:image/png;base64," + d.overlay_png;
$("avviso").textContent = d.scale_spread_pct > 1
? "Attenzione: la scala varia di " + numero(d.scale_spread_pct, 2) + " % nell'area: scacchiera inclinata o ottica non telecentrica."
: "";
$("risultato").hidden = false;
messaggio(d.saved ? "Calibrazione salvata." : "Calcolo riuscito: controllare e salvare.", "ok");
if (d.saved) aggiornaStato();
} catch (e) {
messaggio("Servizio non raggiungibile.", "errore");
} finally {
["acquisisci", "carica", "salva"].forEach(function (i) { $(i).disabled = false; });
}
}
$("acquisisci").onclick = function () { calcola(false, false); };
$("carica").onclick = function () { calcola(true, false); };
// «Salva» rifa il calcolo con save=true sulla stessa origine (altra acquisizione se da camera)
$("salva").onclick = function () { calcola(ultimo ? ultimo.conFile : false, true); };
$("elimina").onclick = async function () {
var sorgente = $("sorgente").value;
if (!confirm("Eliminare la calibrazione salvata di " + sorgente + "?")) return;
var r = await fetch("/camera/calibration?source=" + encodeURIComponent(sorgente), { method: "DELETE" });
messaggio(r.ok ? "Calibrazione eliminata." : await dettaglio(r), r.ok ? "ok" : "errore");
aggiornaStato();
};
$("sorgente").onchange = aggiornaStato;
caricaSorgenti();
</script>
</body>
</html>
"""
+114 -21
View File
@@ -6,17 +6,33 @@ OpenCV, cartella di immagini) con lo stesso contratto di `vs-gui`, ma solo
GET /camera/sources -> {"sources": [{name, kind, label}]} 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} -> PNG
Errori: 400 parametri, 404 sorgente inesistente, 503 guasto camera, 504 timeout. Errori: 400 parametri, 404 sorgente inesistente, 503 guasto camera, 504 timeout.
Calibrazione a scacchiera per sorgente (#83, logica in scripts/calibrazione.py):
GET /camera/calibration?source=<src> -> 200 {source, px_per_mm, mm_per_px,
rms_px, n_corners, cols, rows, square_mm, image_width, image_height,
scale_spread_pct, calibrated_at} | 404 "sorgente non calibrata"
POST /camera/calibrate multipart: source, cols, rows (angoli interni),
square_mm, image (file, opzionale: senza, grab dalla sorgente come
/camera/grab), save=false -> campi di sopra + overlay_png (b64) + saved;
422 scacchiera non trovata o parametri invalidi
DELETE /camera/calibration?source=<src> -> 204
GET /calibrazione -> pagina HTML autonoma (offline) per fare tutto a mano
Il risultato salvato sta in un JSON per sorgente in --config-dir (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 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. raggiunge; questo servizio permette di scegliere l'host di ascolto.
Uso (dalla radice del repo): Uso (dalla radice del repo):
vendor/visionsuite/.venv/bin/python scripts/camera_service.py \ 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). --folder abilita la sorgente finta `camera:folder` (loop sulle immagini).
Da Docker su Linux: --host 172.17.0.1 (gateway di docker0). Da Docker su Linux: --host 172.17.0.1 (gateway di docker0).
Dipende da: venv di VisionSuite (`uv sync --all-packages` in vendor/visionsuite): 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 AVVERTENZA: nessuna autenticazione. Un --host diverso dal loopback espone la
camera a chiunque raggiunga la porta: solo su rete isolata. camera a chiunque raggiunga la porta: solo su rete isolata.
@@ -24,17 +40,26 @@ camera a chiunque raggiunga la porta: solo su rete isolata.
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import os
import sys
import threading import threading
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
import cv2 import cv2
from fastapi import Body, FastAPI, HTTPException import numpy as np
from fastapi.responses import Response 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
from visionsuite.camera.sources import SourceNotFound, list_sources, open_source 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",) _TRIGGER = ("continuo",)
# errori noti di vs-camera (inglese) -> italiano; esatti, poi per prefisso # errori noti di vs-camera (inglese) -> italiano; esatti, poi per prefisso
@@ -61,7 +86,18 @@ def _traduci(messaggio: str) -> str:
return messaggio 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 = {} aperte: dict = {}
lock = threading.Lock() # protegge SOLO i dizionari, mai un'apertura lock = threading.Lock() # protegge SOLO i dizionari, mai un'apertura
lock_sorgenti: dict = {} # nome sorgente -> Lock che serializza apertura e grab lock_sorgenti: dict = {} # nome sorgente -> Lock che serializza apertura e grab
@@ -95,6 +131,24 @@ def create_app(folder: Path | str | None = None) -> FastAPI:
return {"sources": [{"name": v.name, "kind": v.kind, "label": v.label} return {"sources": [{"name": v.name, "kind": v.kind, "label": v.label}
for v in voci if v.kind in ("camera", "virtual")]} for v in voci if v.kind in ("camera", "virtual")]}
def _grab(nome: str, timeout: int):
"""Un frame dalla sorgente (apre e tiene aperta); errori camera -> HTTP."""
try:
with _lock_di(nome): # solo questa sorgente; le altre non aspettano
with lock:
sorgente = aperte.get(nome)
if sorgente is None:
sorgente = open_source(nome, None, config=_config())
with lock:
aperte[nome] = sorgente
return sorgente.grab(timeout)
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)))
@app.post("/camera/grab") @app.post("/camera/grab")
def camera_grab(corpo: dict = Body(default=None)): def camera_grab(corpo: dict = Body(default=None)):
nome = corpo.get("source") if isinstance(corpo, dict) else None nome = corpo.get("source") if isinstance(corpo, dict) else None
@@ -107,26 +161,63 @@ def create_app(folder: Path | str | None = None) -> FastAPI:
timeout = corpo.get("timeout_ms", 5000) timeout = corpo.get("timeout_ms", 5000)
if isinstance(timeout, bool) or not isinstance(timeout, int) or not 1 <= timeout <= 10000: 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") raise HTTPException(400, "«timeout_ms» deve essere un intero fra 1 e 10000")
try: frame = _grab(nome, timeout) # #83: corpo estratto in _grab, invariato
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)))
ok, buf = cv2.imencode(".png", frame) ok, buf = cv2.imencode(".png", frame)
if not ok: if not ok:
raise HTTPException(500, "codifica PNG fallita") raise HTTPException(500, "codifica PNG fallita")
return Response(buf.tobytes(), media_type="image/png") return Response(buf.tobytes(), media_type="image/png")
# ---- #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))
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.elimina(_sorgente(source))
return Response(status_code=204)
@app.post("/camera/calibrate")
async def calibrazione_calcola(
source: str = Form(...),
cols: int = Form(...),
rows: int = Form(...),
square_mm: float = Form(...),
image: UploadFile | None = File(None),
save: bool = Form(False),
):
nome = _sorgente(source)
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")
else:
frame = await run_in_threadpool(_grab, nome, 5000) # come /camera/grab (404/503/504)
try:
risultato = calibrazione.calibra_scacchiera(frame, cols, rows, square_mm)
except calibrazione.CalibrazioneError as e:
raise HTTPException(422, str(e))
corpo = {"source": nome,
**{k: v for k, v in risultato.items() if k != "overlay_png"},
"calibrated_at": calibrazione.ora_iso()}
if save:
archivio.salva(nome, corpo)
return {**corpo, "overlay_png": risultato["overlay_png"], "saved": bool(save)}
@app.get("/calibrazione", response_class=HTMLResponse)
def calibrazione_pagina():
return HTMLResponse(PAGINA)
return app return app
@@ -136,8 +227,10 @@ def main() -> None:
p.add_argument("--host", default="127.0.0.1") p.add_argument("--host", default="127.0.0.1")
p.add_argument("--port", type=int, default=8765) p.add_argument("--port", type=int, default=8765)
p.add_argument("--folder", default=None, help="cartella immagini per camera:folder") 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() 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__": if __name__ == "__main__":
+184
View File
@@ -0,0 +1,184 @@
"""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 _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 = {"source", "px_per_mm", "mm_per_px", "rms_px", "n_corners", "cols", "rows",
"square_mm", "image_width", "image_height", "scale_spread_pct", "calibrated_at"}
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_C1 | {"overlay_png", "saved"} == set(corpo)
assert corpo["saved"] is False
assert corpo["px_per_mm"] == pytest.approx(20.0, rel=0.005)
# senza save non si scrive nulla
assert client.get("/camera/calibration", params={"source": "camera:folder"}).status_code == 404
assert not client.config.exists() or not list(client.config.glob("*.json"))
def test_c2_c1_c3_ciclo_con_grab_dalla_sorgente(client):
# nessun file: grab da camera:folder (la scacchiera in cartella), con save
r = client.post("/camera/calibrate",
data={"source": "camera:folder", "save": "true", **CAMPI})
assert r.status_code == 200, r.text
assert r.json()["saved"] is True
r1 = client.get("/camera/calibration", params={"source": "camera:folder"})
assert r1.status_code == 200
assert set(r1.json()) == CHIAVI_C1 # nessun overlay nel file salvato
assert r1.json()["px_per_mm"] == pytest.approx(20.0, rel=0.005)
assert r1.json()["source"] == "camera:folder"
file_salvati = list(client.config.glob("*.json"))
assert len(file_salvati) == 1
assert json.loads(file_salvati[0].read_text(encoding="utf-8"))["cols"] == 9
assert client.delete("/camera/calibration", params={"source": "camera:folder"}).status_code == 204
assert client.get("/camera/calibration", params={"source": "camera:folder"}).status_code == 404
# cancellare 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_sorgenti_hanno_file_diversi(client):
for nome in ("camera:a b", "camera:a_b"):
r = client.post("/camera/calibrate", data={"source": nome, "save": "true", **CAMPI},
files={"image": ("s.png", _png(una_scacchiera()), "image/png")})
assert r.status_code == 200
assert len(list(client.config.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 calcola" in r.text
assert "http://" not in r.text and "https://" not in r.text # offline, niente CDN
+7 -2
View File
@@ -3,6 +3,7 @@
Protected by SETUP_PASSWORD env var. When empty/None, all endpoints return 404. Protected by SETUP_PASSWORD env var. When empty/None, all endpoints return 404.
NOT protected by API Key auth (standalone access). NOT protected by API Key auth (standalone access).
""" """
import asyncio
import hmac import hmac
import random import random
from datetime import datetime, timedelta from datetime import datetime, timedelta
@@ -312,7 +313,9 @@ async def init_db(body: PasswordBody):
await _require_not_initialised() await _require_not_initialised()
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
_stamp_head() # #80: env.py di alembic fa asyncio.run(), vietato dentro il loop di FastAPI;
# in un thread non c'e' un loop attivo e funziona.
await asyncio.to_thread(_stamp_head)
return {"status": "ok", "message": "Database tables created successfully"} return {"status": "ok", "message": "Database tables created successfully"}
@@ -748,6 +751,8 @@ async def reset_db(body: ResetDbBody):
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all) await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
_stamp_head() # #80: env.py di alembic fa asyncio.run(), vietato dentro il loop di FastAPI;
# in un thread non c'e' un loop attivo e funziona.
await asyncio.to_thread(_stamp_head)
return {"status": "ok", "message": "Database reset successfully"} return {"status": "ok", "message": "Database reset successfully"}
+81
View File
@@ -0,0 +1,81 @@
"""init-db / reset-db con _stamp_head REALE su un file SQLite (TieMeasureFlow#80).
_stamp_head esegue migrations/env.py, che chiama asyncio.run(): dentro il loop
gia' attivo di FastAPI dava RuntimeError -> 500. test_setup.py e
test_station_seed.py sostituiscono _stamp_head con un no-op e non lo vedevano:
qui NON si fa monkeypatch di _stamp_head.
"""
import logging.config
import sqlite3
import pytest
from alembic.config import Config
from alembic.script import ScriptDirectory
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from src.backend.config import settings
from src.backend.main import app
SETUP_PWD = "test-setup-password"
@pytest.fixture
def file_db(tmp_path, monkeypatch):
"""Punta settings, engine e session factory del router setup a un file SQLite."""
import src.backend.api.routers.setup as setup_mod
db_file = tmp_path / "t.db"
url = f"sqlite+aiosqlite:///{db_file.as_posix()}"
# env.py ricava sqlalchemy.url da settings.database_url (property su db_url)
monkeypatch.setattr(settings, "db_url", url)
monkeypatch.setattr(settings, "setup_password", SETUP_PWD)
file_engine = create_async_engine(url, echo=False)
monkeypatch.setattr(setup_mod, "engine", file_engine)
monkeypatch.setattr(
setup_mod, "async_session_factory",
async_sessionmaker(file_engine, expire_on_commit=False),
)
# env.py chiama fileConfig, che disattiverebbe i logger degli altri test
monkeypatch.setattr(logging.config, "fileConfig", lambda *a, **k: None)
return db_file, setup_mod, file_engine
def _head() -> str:
from src.backend.api.routers.setup import _alembic_ini
cfg = Config(str(_alembic_ini))
return ScriptDirectory.from_config(cfg).get_current_head()
def _versions(db_file) -> list[str]:
con = sqlite3.connect(db_file)
try:
return [r[0] for r in con.execute("SELECT version_num FROM alembic_version")]
finally:
con.close()
@pytest.fixture
async def http():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as ac:
yield ac
@pytest.mark.asyncio
async def test_init_db_stamps_head(file_db, http):
db_file, _, file_engine = file_db
resp = await http.post("/api/setup/init-db", json={"password": SETUP_PWD})
assert resp.status_code == 200, resp.text
assert _versions(db_file) == [_head()]
await file_engine.dispose()
@pytest.mark.asyncio
async def test_reset_db_stamps_head(file_db, http):
db_file, _, file_engine = file_db
resp = await http.post("/api/setup/init-db", json={"password": SETUP_PWD})
assert resp.status_code == 200, resp.text
resp = await http.post("/api/setup/reset-db", json={"password": SETUP_PWD, "confirm": True})
assert resp.status_code == 200, resp.text
assert _versions(db_file) == [_head()]
await file_engine.dispose()
+16 -18
View File
@@ -12,12 +12,9 @@ class TestSelectRecipe:
def test_select_recipe_renders(self, logged_in_client, mock_api_client, monkeypatch): def test_select_recipe_renders(self, logged_in_client, mock_api_client, monkeypatch):
"""Recipe selection page renders for MeasurementTec role.""" """Recipe selection page renders for MeasurementTec role."""
monkeypatch.setenv("STATION_CODE", "ST-TEST") # Patch the class attribute instead of reload()ing config/blueprints.measure:
import config # monkeypatch undoes it at teardown, so no ST-TEST leaks into later tests (#79).
import importlib monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
importlib.reload(config)
import blueprints.measure
importlib.reload(blueprints.measure)
mock_api_client.get_station_recipes.return_value = [ mock_api_client.get_station_recipes.return_value = [
{"id": 1, "code": "REC-001", "name": "Test Recipe"}, {"id": 1, "code": "REC-001", "name": "Test Recipe"},
@@ -47,12 +44,9 @@ class TestSelectRecipe:
import re import re
from html.parser import HTMLParser from html.parser import HTMLParser
monkeypatch.setenv("STATION_CODE", "ST-TEST") # Patch the class attribute instead of reload()ing config/blueprints.measure:
import config # monkeypatch undoes it at teardown, so no ST-TEST leaks into later tests (#79).
import importlib monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
importlib.reload(config)
import blueprints.measure
importlib.reload(blueprints.measure)
mock_api_client.get_station_recipes.return_value = [] mock_api_client.get_station_recipes.return_value = []
@@ -79,12 +73,9 @@ class TestSelectRecipe:
self, logged_in_client, mock_api_client, monkeypatch self, logged_in_client, mock_api_client, monkeypatch
): ):
"""#10: a lot number like ``L'23`` must not break the page.""" """#10: a lot number like ``L'23`` must not break the page."""
monkeypatch.setenv("STATION_CODE", "ST-TEST") # Patch the class attribute instead of reload()ing config/blueprints.measure:
import config # monkeypatch undoes it at teardown, so no ST-TEST leaks into later tests (#79).
import importlib monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
importlib.reload(config)
import blueprints.measure
importlib.reload(blueprints.measure)
mock_api_client.get_station_recipes.return_value = [] mock_api_client.get_station_recipes.return_value = []
@@ -393,6 +384,13 @@ class TestStartSequence:
class TestTaskComplete: class TestTaskComplete:
"""GET /measure/complete/<recipe_id> tests.""" """GET /measure/complete/<recipe_id> tests."""
@pytest.fixture(autouse=True)
def _station(self, monkeypatch):
# The side_effect lists below include the /production-runs/current reply,
# which is only requested when a station is configured: pin it explicitly
# instead of relying on whatever an earlier test left behind (#79).
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
def test_task_complete_renders(self, logged_in_client, mock_api_client): def test_task_complete_renders(self, logged_in_client, mock_api_client):
"""The summary carries the measurement clock, so it has to render with it.""" """The summary carries the measurement clock, so it has to render with it."""
mock_api_client.get.side_effect = [ mock_api_client.get.side_effect = [
@@ -6,6 +6,17 @@ a start button that will not start, a description that reads the way it was
written. written.
""" """
import pytest
@pytest.fixture(autouse=True)
def _no_station(monkeypatch):
# These tests assume no station is configured (e.g. _task_list feeds the mock a
# fixed number of replies, with no /production-runs/current among them); pin it
# so a STATION_CODE left behind by an earlier test cannot change the call count (#79).
# Tests that need a station set it themselves, after this fixture has run.
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", None)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Point 11 - line breaks and bold, and nothing else # Point 11 - line breaks and bold, and nothing else
@@ -197,13 +208,9 @@ def test_avvia_is_untouched_when_nothing_is_required(
def test_selection_screen_carries_the_traceability_rules( def test_selection_screen_carries_the_traceability_rules(
logged_in_client, mock_api_client, monkeypatch, logged_in_client, mock_api_client, monkeypatch,
): ):
monkeypatch.setenv("STATION_CODE", "ST-TEST") # Patch instead of reload()ing config/blueprints.measure: the patch is undone at
import importlib # teardown, a reload would leave ST-TEST behind for later tests (#79).
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
import config
importlib.reload(config)
import blueprints.measure
importlib.reload(blueprints.measure)
mock_api_client.get_station_recipes.return_value = [ mock_api_client.get_station_recipes.return_value = [
{ {
+228
View File
@@ -0,0 +1,228 @@
"""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 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 _candidate_anchors(gray: np.ndarray, dxf_path: Path, px_per_mm: float) -> 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).
"""
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())
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 = []
for theta in _START_ANGLES:
# traslazione che porta il centroide del disegno ruotato sul misurato
rr, cc = Pose(0.0, 0.0, theta).apply(dc[0], dc[1])
start = Pose(float(mc[0] - rr), float(mc[1] - cc), theta)
try:
result = cmp_.compare(Image(gray), initial_pose=start)
except Exception: # noqa: BLE001 - un orientamento che cade non ferma gli altri
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))
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)
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,
}
+114
View File
@@ -0,0 +1,114 @@
"""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,
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)
+65
View File
@@ -17,6 +17,7 @@ import numpy as np
from fastapi import FastAPI, File, Form, HTTPException, UploadFile, status from fastapi import FastAPI, File, Form, HTTPException, UploadFile, status
from PIL import Image, UnidentifiedImageError from PIL import Image, UnidentifiedImageError
from src.vision.costruttori import CostruttoreError, build_dxf_compare # #83
from src.vision.runner import ( from src.vision.runner import (
GraphBuildError, GraphBuildError,
RunOutcome, RunOutcome,
@@ -223,3 +224,67 @@ async def validate(
validate_graph, parsed_graph, resolved_dir validate_graph, parsed_graph, resolved_dir
) )
return {"ok": ok, "errors": errors, "outputs": outputs} 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