Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f2d624b4f | |||
| 9fe82d7cef | |||
| ae2353f7f8 |
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user