Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f504c86491 | |||
| 8eb80e20a8 | |||
| b2f82fe779 | |||
| 3c5c5f9bc5 | |||
| a5e8472e88 | |||
| d6cee498ad | |||
| e7d5489438 | |||
| 828471d27a | |||
| cf64a247be | |||
| 813b66d0c5 | |||
| 8c6d6c1630 | |||
| 1afaaa0bae | |||
| cb66371a5b | |||
| 390d6210d0 | |||
| 2760623b53 | |||
| 13eeec7119 | |||
| 0629f1b5ec | |||
| 9eb5942459 | |||
| 11c7d2e98b | |||
| 88bba213e2 | |||
| 8ce7b489d6 | |||
| bc90d327f1 | |||
| e46965cb51 | |||
| c554fcfda7 | |||
| 08e46e335b | |||
| e38e1a2eaf | |||
| 7fa5bacdfe | |||
| 896a30b376 | |||
| 13dbc8f13b | |||
| 8925968f34 | |||
| 2959b79fa2 | |||
| 990b5d0db8 | |||
| 1bfcfaa5f2 | |||
| ad6ce5d11d | |||
| 351739ec36 | |||
| 9f2d624b4f | |||
| 9fe82d7cef | |||
| ae2353f7f8 | |||
| d652891c1c | |||
| 0b22643d69 | |||
| 15e31e2b8a | |||
| f8f5f248ff | |||
| f33415df6a | |||
| 62b5e412ca | |||
| 0e92959022 | |||
| f23e87e585 | |||
| 114e4a10ae | |||
| 3aac8cc490 | |||
| 8b3e897668 | |||
| eea3e319fb | |||
| bfc7c391e8 | |||
| 8d4c4020f6 | |||
| 0a68a9995e | |||
| 959e92552b | |||
| 8431aa7095 | |||
| 69cbf53060 | |||
| 23012fdfbb | |||
| d89a2e6c8d | |||
| 242add047b | |||
| 09c4029978 | |||
| cd40b81f1e | |||
| 6e60e0b1a9 |
@@ -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
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# TieMeasureFlow#76: setup.exe di TieMeasureFlow per Windows, generato al tag v*.
|
||||||
|
# Cosa fa: submodule vendor/visionsuite (deploy key), build.ps1 (uv sync Python 3.13, Tailwind,
|
||||||
|
# Nuitka --standalone, migrazioni .pyc, DLL di WeasyPrint, Inno Setup), poi il setup come
|
||||||
|
# artifact e come asset della release Gitea del tag.
|
||||||
|
# Da cosa dipende: un runner Gitea con label `windows` che giri su un host Windows (non in
|
||||||
|
# container) con Visual Studio/Build Tools (MSVC), git e node (serve a actions/checkout e a
|
||||||
|
# Tailwind); uv e Inno Setup li installa il workflow se mancano. Secret SUBMODULI_SSH_KEY
|
||||||
|
# (deploy key di sola lettura di TieVision-Core, impostato sul repo il 17/10/2026).
|
||||||
|
# Il runner `windows` e' act_runner in modalita' host su daitarn (capacity 1).
|
||||||
|
# ⚠️ upload-artifact@v3: e' la versione che usa gia' vs-native.yml in TieVision-Core su questo
|
||||||
|
# Gitea; la v4 usa un protocollo che act_runner puo' non supportare.
|
||||||
|
# ⚠️ L'asset della release usa il token del job (secrets.GITHUB_TOKEN): se la chiamata risponde
|
||||||
|
# 403 serve un token personale in un secret (es. RILASCI_TOKEN) al posto di GITHUB_TOKEN.
|
||||||
|
# ⚠️ La build da freddo e' lunga (Nuitka compila backend, visione, plotly, opencv...): timeout 180 min.
|
||||||
|
name: rilascio-windows
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ['v*']
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
setup-windows:
|
||||||
|
runs-on: windows
|
||||||
|
timeout-minutes: 180
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: powershell
|
||||||
|
steps:
|
||||||
|
# Checkout senza ssh-key (come ci.yml): i submodule si inizializzano a parte, con la deploy key.
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Submodule privati (deploy key in sola lettura)
|
||||||
|
env:
|
||||||
|
SUBMODULI_SSH_KEY: ${{ secrets.SUBMODULI_SSH_KEY }}
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
# cartella temporanea del job, non ~/.ssh: su un runner host e' l'account dell'utente
|
||||||
|
$ssh = Join-Path $env:TEMP ("submoduli-" + [guid]::NewGuid().ToString('N'))
|
||||||
|
New-Item -ItemType Directory -Force $ssh | Out-Null
|
||||||
|
$chiave = Join-Path $ssh 'chiave'
|
||||||
|
# LF e newline finale: OpenSSH rifiuta una chiave con CRLF
|
||||||
|
[System.IO.File]::WriteAllText($chiave, ($env:SUBMODULI_SSH_KEY.Trim() + "`n"), (New-Object System.Text.UTF8Encoding $false))
|
||||||
|
# OpenSSH su Windows rifiuta una chiave leggibile da altri utenti; (F) e non (R):
|
||||||
|
# con la sola lettura Remove-Item finale non puo' cancellarla
|
||||||
|
icacls $chiave /inheritance:r | Out-Null
|
||||||
|
icacls $chiave /grant:r "$($env:USERNAME):(F)" | Out-Null
|
||||||
|
$known = Join-Path $ssh 'known_hosts'
|
||||||
|
[System.IO.File]::WriteAllText($known, "[git.tielogic.xyz]:222 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDvABW6Tvruqkf7KJhJZUiAX6NeCx0enwcNXzZbECQ8J`n", (New-Object System.Text.UTF8Encoding $false))
|
||||||
|
$env:GIT_SSH_COMMAND = "ssh -i $($chiave -replace '\\','/') -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$($known -replace '\\','/')"
|
||||||
|
try {
|
||||||
|
git submodule update --init --recursive
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "git submodule update fallito" }
|
||||||
|
} finally {
|
||||||
|
# la deploy key non resta mai sul disco del runner
|
||||||
|
Remove-Item $ssh -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: uv (se manca)
|
||||||
|
run: |
|
||||||
|
if (-not (Get-Command uv -ErrorAction SilentlyContinue)) {
|
||||||
|
irm https://astral.sh/uv/install.ps1 | iex
|
||||||
|
}
|
||||||
|
# il PATH del passo successivo
|
||||||
|
"$env:USERPROFILE\.local\bin" | Out-File -Append -Encoding utf8 $env:GITHUB_PATH
|
||||||
|
|
||||||
|
- name: Node (se manca)
|
||||||
|
run: |
|
||||||
|
# serve a Tailwind (npx) in build.ps1
|
||||||
|
if (-not (Get-Command npx -ErrorAction SilentlyContinue)) {
|
||||||
|
winget install --id OpenJS.NodeJS.LTS -e --silent --accept-package-agreements --accept-source-agreements
|
||||||
|
}
|
||||||
|
"$env:ProgramFiles\nodejs" | Out-File -Append -Encoding utf8 $env:GITHUB_PATH
|
||||||
|
|
||||||
|
- name: Inno Setup (se manca)
|
||||||
|
run: |
|
||||||
|
$trovato = (Get-Command ISCC.exe -ErrorAction SilentlyContinue) -or
|
||||||
|
(Test-Path "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe") -or
|
||||||
|
(Test-Path "$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe")
|
||||||
|
if (-not $trovato) {
|
||||||
|
winget install --id JRSoftware.InnoSetup -e --silent --accept-package-agreements --accept-source-agreements
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Build (Nuitka + Inno Setup)
|
||||||
|
# GITHUB_REF_NAME e' il tag (v0.1.0); con workflow_dispatch e' il ramo: build.ps1 rifiuta
|
||||||
|
# un nome senza numeri, quindi in quel caso si passa 0.0.0
|
||||||
|
run: |
|
||||||
|
$v = '${{ github.ref_name }}'
|
||||||
|
if ($v -notmatch '^v?\d+\.\d+\.\d+') { $v = '0.0.0' }
|
||||||
|
powershell -ExecutionPolicy Bypass -File packaging\windows\build.ps1 -Versione $v
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "build.ps1 fallito" }
|
||||||
|
|
||||||
|
- name: Setup come artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: TieMeasureFlow-setup
|
||||||
|
path: dist/TieMeasureFlow-setup-*.exe
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Asset della release Gitea (solo ai tag)
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$h = @{ Authorization = "token $env:TOKEN" }
|
||||||
|
# la release del tag: la crea il primo job che arriva, poi si riusa
|
||||||
|
try {
|
||||||
|
$rel = Invoke-RestMethod -Headers $h "$env:API/releases/tags/$env:TAG"
|
||||||
|
} catch {
|
||||||
|
$corpo = @{ tag_name = $env:TAG; name = "TieMeasureFlow $env:TAG" } | ConvertTo-Json
|
||||||
|
$rel = Invoke-RestMethod -Method Post -Headers $h -ContentType 'application/json' -Body $corpo "$env:API/releases"
|
||||||
|
}
|
||||||
|
$setup = Get-ChildItem dist\TieMeasureFlow-setup-*.exe | Select-Object -First 1
|
||||||
|
# curl.exe (incluso in Windows 10+) fa il multipart senza codice a mano
|
||||||
|
curl.exe --fail -sS -X POST -H "Authorization: token $env:TOKEN" -F "attachment=@$($setup.FullName)" "$env:API/releases/$($rel.id)/assets?name=$($setup.Name)"
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "caricamento dell'asset fallito" }
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -289,7 +289,7 @@ Nginx (dev): gzip abilitato, `client_max_body_size 50M`, `proxy_read_timeout 120
|
|||||||
|
|
||||||
Il runner di visione (`src/vision/`) e il suo worker FastAPI (`src/vision_worker/`) dipendono da `vendor/visionsuite`, un **submodule** che punta al repo TieVision-Core (`ssh://git@git.tielogic.xyz:222/Adriano/TieVision-Core.git`, ex visionsuite, rinominato il 15/10/2026; `vs-gui` e `vs-assistant` sono ora in TieVision-Studio e TieVision-Assistant, che TieMeasureFlow non usa; il path resta `vendor/visionsuite`): un `git clone` senza `--recurse-submodules` lascia la cartella vuota (chi ha già clonato: `git submodule update --init`). Le librerie di VisionSuite richiedono **Python 3.13**, mentre backend e frontend restano su Python 3.11 — è un albero a parte nel monorepo, isolato con il proprio extra `uv`.
|
Il runner di visione (`src/vision/`) e il suo worker FastAPI (`src/vision_worker/`) dipendono da `vendor/visionsuite`, un **submodule** che punta al repo TieVision-Core (`ssh://git@git.tielogic.xyz:222/Adriano/TieVision-Core.git`, ex visionsuite, rinominato il 15/10/2026; `vs-gui` e `vs-assistant` sono ora in TieVision-Studio e TieVision-Assistant, che TieMeasureFlow non usa; il path resta `vendor/visionsuite`): un `git clone` senza `--recurse-submodules` lascia la cartella vuota (chi ha già clonato: `git submodule update --init`). Le librerie di VisionSuite richiedono **Python 3.13**, mentre backend e frontend restano su Python 3.11 — è un albero a parte nel monorepo, isolato con il proprio extra `uv`.
|
||||||
|
|
||||||
Regola (Adriano, 07/10/2026): il submodule `vendor/visionsuite` segue `origin/main`: a ogni integrazione si porta all'ultimo commit (`git -C vendor/visionsuite fetch origin && git -C vendor/visionsuite checkout origin/main`) e si rilanciano i test della visione. La regola vale ora per `origin/main` di TieVision-Core. Pin attuale: 4c178cf.
|
Regola (Adriano, 07/10/2026): il submodule `vendor/visionsuite` segue `origin/main`: a ogni integrazione si porta all'ultimo commit (`git -C vendor/visionsuite fetch origin && git -C vendor/visionsuite checkout origin/main`) e si rilanciano i test della visione. La regola vale ora per `origin/main` di TieVision-Core. Pin attuale: 2e73691.
|
||||||
|
|
||||||
Il worker gira in un container separato (`Dockerfile.vision`), non pubblica porte verso l'esterno ed è raggiungibile solo dal server all'indirizzo interno `http://vision:8100`. Il container non ha accesso al repository Git del progetto principale, quindi il commit di VisionSuite in uso (`engine_version`) non può essere scoperto dentro l'immagine: va stampato al momento del build leggendo il commit dalla macchina che esegue `docker compose build`, con la variabile `VISION_ENGINE_VERSION`. Una build senza questa variabile fallisce subito, invece di produrre un worker che risponde con una versione indovinata:
|
Il worker gira in un container separato (`Dockerfile.vision`), non pubblica porte verso l'esterno ed è raggiungibile solo dal server all'indirizzo interno `http://vision:8100`. Il container non ha accesso al repository Git del progetto principale, quindi il commit di VisionSuite in uso (`engine_version`) non può essere scoperto dentro l'immagine: va stampato al momento del build leggendo il commit dalla macchina che esegue `docker compose build`, con la variabile `VISION_ENGINE_VERSION`. Una build senza questa variabile fallisce subito, invece di produrre un worker che risponde con una versione indovinata:
|
||||||
|
|
||||||
@@ -335,3 +335,25 @@ Variabili d'ambiente in `.env` (copiare da `.env.example`):
|
|||||||
- Font UI: Inter
|
- Font UI: Inter
|
||||||
- Font numeri: JetBrains Mono
|
- Font numeri: JetBrains Mono
|
||||||
- Dark mode: `darkMode: 'class'` in tailwind.config.js, toggle via `Alpine.store('theme')` con localStorage key `tmf-theme`
|
- Dark mode: `darkMode: 'class'` in tailwind.config.js, toggle via `Alpine.store('theme')` con localStorage key `tmf-theme`
|
||||||
|
|
||||||
|
## Pacchetto Windows (TieMeasureFlow#76)
|
||||||
|
|
||||||
|
- File: `packaging/windows/avvio.py` (entry point: licenza, `config.env`, variabili d'ambiente, migrazioni, quattro server in thread), `build.ps1` (uv sync 3.13, Tailwind, Nuitka `--standalone`, migrazioni come `.pyc`, Inno Setup), `TieMeasureFlow.iss`, `TieMeasureFlow.ico`; CI `.gitea/workflows/rilascio-windows.yml` (tag `v*`, runner con etichetta `windows`); test in `packaging/windows/tests/` (licenza firmata con la chiave di prova del contratto, server e migrazioni sostituiti). Piani collegati: SQLite su file (#77) e PDF/DLL (#78). Modello: TieVision-Studio#1.
|
||||||
|
- Un solo processo `TieMeasureFlow.exe`: thread `web` (Flask + waitress, `--host`, 8780), `backend` (8000), `vision` (8100), `camera` (8765) con uvicorn. Dati in `%PROGRAMDATA%\Tielogic\TieMeasureFlow` (`tmf.db`, `uploads`, `camera`, `log`, `config.env`); licenza in `%PROGRAMDATA%\Tielogic\licenze\tiemeasureflow.json` (mai nel repo).
|
||||||
|
- ⚠️ Python del pacchetto: 3.13 in un ambiente separato (`build\venv313`, `UV_PROJECT_ENVIRONMENT`); il repo resta su `.python-version` 3.11 per lo sviluppo. `vs-license` e `vs-camera` hanno il marker `python_version>='3.13'` nel gruppo `windows`, altrimenti `uv lock` fallisce sul `requires-python >=3.11`.
|
||||||
|
- ⚠️ Le variabili d'ambiente si impostano PRIMA di importare backend e frontend (leggono la configurazione all'import): in `avvio.py` gli import pesanti stanno dentro le funzioni. `APP_ENV=production` rifiuta i segreti di default, quindi `config.env` genera anche `DB_PASSWORD` (inutilizzata con SQLite).
|
||||||
|
- ⚠️ Il frontend Flask non è un pacchetto (`from config import Config`, `blueprints`, `services`, `app`) e `scripts/camera_service.py` neppure: da sorgente `avvio.py` aggiunge `flask_app` e `scripts` al `sys.path`; per Nuitka `build.ps1` li mette su `PYTHONPATH` e li include con `--include-module/--include-package`. I dati di Flask (`templates`, `static`, `translations`) vanno nella radice della cartella standalone, `src\backend\templates` sotto `src\backend`.
|
||||||
|
- ⚠️ Il browser parla con Flask (8780); la pagina di setup `/api/setup` invece è del backend: si apre su `http://127.0.0.1:8000/api/setup` (in Docker la serve nginx). `avvio.py` la apre da sola se il database non ha utenti.
|
||||||
|
- ⚠️ 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.
|
||||||
|
- ⚠️ 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.
|
||||||
|
- ⚠️ `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.
|
||||||
|
- ⚠️ `SETUP_PASSWORD` e le chiavi stanno in `config.env`, in una cartella scrivibile da tutti gli utenti locali (serve all'exe, che gira con l'utente che lo lancia): non è un luogo per segreti ad alto valore.
|
||||||
|
- ⚠️ CI di rilascio: il runner `windows` è su daitarn (act_runner, modalità host, capacity 1); `upload-artifact` è alla v3. Non committare `build/` e `dist/`.
|
||||||
|
- ⚠️ `STATION_CODE`: senza una stazione l'interfaccia risponde 503 («Stazione non configurata»). `config.env` nasce con `STATION_CODE=ST-DEFAULT` (la stazione creata dal seed della pagina di setup); le chiavi extra di `config.env` (`STATION_CODE`, `STATION_SWITCH_ENABLED`, ...) passano all'ambiente ma non sovrascrivono le variabili fisse del contratto.
|
||||||
|
- ⚠️ Alembic anche fuori da `avvio.py`: `setup.py` (`_stamp_head`) apre `alembic.ini` con un `Config` suo, quindi `build.ps1` scrive `sourceless = true` nell'`alembic.ini` del pacchetto; e `env.py` chiama `logging.config.fileConfig`, che azzererebbe il log su file, quindi `avvio.py` lo sostituisce con un no-op.
|
||||||
|
- ⚠️ Bug noto, non del pacchetto: `POST /api/setup/init-db` risponde 500 (`asyncio.run() cannot be called from a running event loop`, perché `_stamp_head` esegue `env.py` dentro il loop). Nel pacchetto non serve: le migrazioni creano già le tabelle; la pagina di setup si usa per «Seed» o per creare l'amministratore.
|
||||||
|
- ⚠️ Le richieste di login all'API sono limitate (5 al minuto): negli script di prova distanziarle.
|
||||||
|
|||||||
@@ -532,6 +532,54 @@ Nella schermata Stazioni si compilano `camera_url` (per esempio `http://host.doc
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Pacchetto Windows
|
||||||
|
|
||||||
|
Per i PC senza Docker (per esempio il PC della stazione in fiera) TieMeasureFlow si distribuisce come programma di installazione per Windows, `TieMeasureFlow-setup-<versione>.exe`. Funziona completamente offline, non richiede Docker né MySQL, e non contiene codice sorgente Python: il codice del prodotto è compilato con Nuitka. Il programma parte solo con una licenza valida, legata al computer.
|
||||||
|
|
||||||
|
### Installazione
|
||||||
|
|
||||||
|
1. Eseguire `TieMeasureFlow-setup-<versione>.exe` (serve un account amministratore; per una prova senza amministratore si può lanciare il setup con `/CURRENTUSER`). Per un'installazione silenziosa: `/VERYSILENT /SUPPRESSMSGBOXES /NORESTART`.
|
||||||
|
2. Dal menu Start aprire «Impronta di questo computer» e comunicare l'impronta a Tielogic. L'impronta viene salvata anche in `C:\ProgramData\Tielogic\licenze\impronta-tiemeasureflow.txt`.
|
||||||
|
3. Copiare il file di licenza ricevuto in `C:\ProgramData\Tielogic\licenze\tiemeasureflow.json`. La licenza non è inclusa nel setup e non va mai inserita nel repository.
|
||||||
|
4. Avviare «TieMeasureFlow» dal menu Start o dal desktop. Senza una licenza valida (mancante, scaduta, di un altro computer o di un altro prodotto) il programma mostra il motivo e l'impronta ed esce.
|
||||||
|
|
||||||
|
### Primo avvio
|
||||||
|
|
||||||
|
Il programma gira in un solo processo, senza finestra, e apre il browser. Ascolta solo sul computer locale (`127.0.0.1`); per raggiungere l'interfaccia da altri dispositivi della rete si avvia l'exe con `--host 0.0.0.0` (per esempio da un collegamento personalizzato).
|
||||||
|
|
||||||
|
| Componente | Indirizzo |
|
||||||
|
|---|---|
|
||||||
|
| Interfaccia web (Flask) | http://127.0.0.1:8780/ |
|
||||||
|
| Backend API (FastAPI) | http://127.0.0.1:8000/ |
|
||||||
|
| Worker di visione | http://127.0.0.1:8100/ |
|
||||||
|
| Camera della stazione | http://127.0.0.1:8765/ (`/camera/sources`) |
|
||||||
|
|
||||||
|
Al primo avvio vengono create la cartella dati `C:\ProgramData\Tielogic\TieMeasureFlow` e il file `config.env` con le chiavi segrete generate (`SERVER_SECRET_KEY`, `CLIENT_SECRET_KEY`, `SETUP_PASSWORD`, `DB_PASSWORD`); al riavvio vengono rilette, quindi le sessioni e le password restano valide. Il database è un file SQLite (`tmf.db`); le migrazioni si applicano da sole a ogni avvio.
|
||||||
|
|
||||||
|
Se il database non contiene ancora utenti, il browser si apre direttamente sulla pagina di setup `http://127.0.0.1:8000/api/setup`. La pagina chiede la `SETUP_PASSWORD`, che si legge in `config.env`, e permette di inizializzare il database e creare il primo amministratore (vedi «Setup Iniziale»). Terminato il setup si accede all'interfaccia su `http://127.0.0.1:8780/`.
|
||||||
|
|
||||||
|
Per le camere della stazione si compila nella schermata Stazioni `camera_url` = `http://127.0.0.1:8765` e `camera_source` (`camera:<seriale>` oppure `camera:folder`). Con `camera:folder` il servizio restituisce a rotazione le immagini che si copiano in `C:\ProgramData\Tielogic\TieMeasureFlow\camera`. Per le camere GigE/GenTL il driver (producer `.cti`) è quello installato dal produttore della camera.
|
||||||
|
|
||||||
|
Altre informazioni utili:
|
||||||
|
|
||||||
|
- I log sono in `C:\ProgramData\Tielogic\TieMeasureFlow\log\tiemeasureflow.log`.
|
||||||
|
- Una seconda esecuzione mentre il programma è già attivo apre soltanto il browser.
|
||||||
|
- Per fermare il programma si usa «Arresta TieMeasureFlow» nel menu Start (chiude il processo).
|
||||||
|
- La disinstallazione non cancella licenze, database, file caricati e `config.env`: sono dati del cliente.
|
||||||
|
- I report PDF usano le librerie grafiche incluse nel pacchetto e un browser Chrome o Edge già installato sul computer per i grafici.
|
||||||
|
|
||||||
|
### Come si costruisce
|
||||||
|
|
||||||
|
Il setup lo produce la CI Gitea (`.gitea/workflows/rilascio-windows.yml`) quando si crea un tag `v*`, su un runner con etichetta `windows`; il file compare come allegato della release. In locale, dalla radice del repository con il sottomodulo `vendor/visionsuite` inizializzato:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File packaging\windows\build.ps1 -Versione 3.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Servono `uv`, Node.js, Visual Studio con il carico C++ e Inno Setup 6. Il risultato è `dist\TieMeasureFlow-setup-3.0.0.exe`. La prima compilazione richiede decine di minuti. I test dell'avvio si lanciano con l'ambiente Python 3.13 del pacchetto: `uv run --python 3.13 --extra server --extra client --extra vision-worker --group windows --with pytest pytest packaging/windows/tests -q`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Documentazione
|
## Documentazione
|
||||||
|
|
||||||
Indice completo: [`docs/README.md`](docs/README.md).
|
Indice completo: [`docs/README.md`](docs/README.md).
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
@@ -0,0 +1,81 @@
|
|||||||
|
; Installer di TieMeasureFlow (TieMeasureFlow#76).
|
||||||
|
; Cosa fa: copia la cartella standalone di Nuitka in Program Files\Tielogic\TieMeasureFlow,
|
||||||
|
; crea in ProgramData le cartelle scrivibili da tutti gli utenti (licenze, uploads, camera, log)
|
||||||
|
; e i collegamenti in menu Start e sul desktop.
|
||||||
|
; Come si usa: lo lancia build.ps1 con /DVersione /DVersione4 /DOrigine /DUscita; a mano:
|
||||||
|
; ISCC /DVersione=3.0.0 /DVersione4=3.0.0.0 /DOrigine=build\nuitka\avvio.dist /DUscita=dist TieMeasureFlow.iss
|
||||||
|
; Setup silenzioso: TieMeasureFlow-setup-X.exe /VERYSILENT /SUPPRESSMSGBOXES /NORESTART
|
||||||
|
; (/CURRENTUSER per installare senza amministratore, in %LOCALAPPDATA%\Programs).
|
||||||
|
; ATTENZIONE: la licenza NON e' nel setup: va copiata in {commonappdata}\Tielogic\licenze\tiemeasureflow.json
|
||||||
|
; (la cartella e' scrivibile dagli utenti). La disinstallazione lascia licenze, database (tmf.db),
|
||||||
|
; uploads e config.env: sono dati del cliente.
|
||||||
|
|
||||||
|
#ifndef Versione
|
||||||
|
#define Versione "0.0.0"
|
||||||
|
#endif
|
||||||
|
#ifndef Versione4
|
||||||
|
#define Versione4 "0.0.0.0"
|
||||||
|
#endif
|
||||||
|
#ifndef Origine
|
||||||
|
#define Origine "..\..\build\nuitka\avvio.dist"
|
||||||
|
#endif
|
||||||
|
#ifndef Uscita
|
||||||
|
#define Uscita "..\..\dist"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
[Setup]
|
||||||
|
; AppId proprio di TieMeasureFlow (diverso da quello di TieVision Studio)
|
||||||
|
AppId={{B7D4E2A1-5C93-4F6E-8A0B-3D1F7E9C2A64}
|
||||||
|
AppName=TieMeasureFlow
|
||||||
|
AppVersion={#Versione}
|
||||||
|
AppVerName=TieMeasureFlow {#Versione}
|
||||||
|
AppPublisher=Tielogic
|
||||||
|
DefaultDirName={autopf}\Tielogic\TieMeasureFlow
|
||||||
|
DefaultGroupName=Tielogic\TieMeasureFlow
|
||||||
|
PrivilegesRequired=admin
|
||||||
|
; /CURRENTUSER solo da riga di comando: serve alle prove senza amministratore
|
||||||
|
PrivilegesRequiredOverridesAllowed=commandline
|
||||||
|
ArchitecturesAllowed=x64compatible
|
||||||
|
ArchitecturesInstallIn64BitMode=x64compatible
|
||||||
|
OutputDir={#Uscita}
|
||||||
|
OutputBaseFilename=TieMeasureFlow-setup-{#Versione}
|
||||||
|
SetupIconFile=TieMeasureFlow.ico
|
||||||
|
UninstallDisplayIcon={app}\TieMeasureFlow.exe
|
||||||
|
VersionInfoVersion={#Versione4}
|
||||||
|
VersionInfoCompany=Tielogic
|
||||||
|
VersionInfoProductName=TieMeasureFlow
|
||||||
|
Compression=lzma2/max
|
||||||
|
SolidCompression=yes
|
||||||
|
WizardStyle=modern
|
||||||
|
DisableProgramGroupPage=yes
|
||||||
|
; il programma in esecuzione tiene aperti i file: Inno chiede di chiuderlo (in silenzioso lo chiude)
|
||||||
|
CloseApplications=yes
|
||||||
|
|
||||||
|
[Languages]
|
||||||
|
Name: "italiano"; MessagesFile: "compiler:Languages\Italian.isl"
|
||||||
|
|
||||||
|
[Tasks]
|
||||||
|
Name: "desktopicon"; Description: "Crea un collegamento sul desktop"; GroupDescription: "Collegamenti:"
|
||||||
|
|
||||||
|
[Dirs]
|
||||||
|
; scrivibili da tutti gli utenti: l'exe gira con l'utente che lo lancia, non con admin
|
||||||
|
Name: "{commonappdata}\Tielogic\licenze"; Permissions: users-modify
|
||||||
|
Name: "{commonappdata}\Tielogic\TieMeasureFlow"; Permissions: users-modify
|
||||||
|
Name: "{commonappdata}\Tielogic\TieMeasureFlow\uploads"; Permissions: users-modify
|
||||||
|
Name: "{commonappdata}\Tielogic\TieMeasureFlow\camera"; Permissions: users-modify
|
||||||
|
Name: "{commonappdata}\Tielogic\TieMeasureFlow\log"; Permissions: users-modify
|
||||||
|
|
||||||
|
[Files]
|
||||||
|
Source: "{#Origine}\*"; DestDir: "{app}"; Flags: recursesubdirs createallsubdirs ignoreversion
|
||||||
|
|
||||||
|
[Icons]
|
||||||
|
Name: "{group}\TieMeasureFlow"; Filename: "{app}\TieMeasureFlow.exe"
|
||||||
|
Name: "{group}\Impronta di questo computer"; Filename: "{app}\TieMeasureFlow.exe"; Parameters: "--impronta"
|
||||||
|
; l'exe non ha finestra: per fermarlo si chiude il processo
|
||||||
|
Name: "{group}\Arresta TieMeasureFlow"; Filename: "{sys}\taskkill.exe"; Parameters: "/IM TieMeasureFlow.exe /F"; Flags: runminimized
|
||||||
|
Name: "{group}\Cartella dati e configurazione"; Filename: "{commonappdata}\Tielogic\TieMeasureFlow"
|
||||||
|
Name: "{group}\Disinstalla TieMeasureFlow"; Filename: "{uninstallexe}"
|
||||||
|
Name: "{autodesktop}\TieMeasureFlow"; Filename: "{app}\TieMeasureFlow.exe"; Tasks: desktopicon
|
||||||
|
|
||||||
|
[Run]
|
||||||
|
Filename: "{app}\TieMeasureFlow.exe"; Description: "Avvia TieMeasureFlow"; Flags: nowait postinstall skipifsilent
|
||||||
@@ -0,0 +1,536 @@
|
|||||||
|
"""Entry point del pacchetto Windows di TieMeasureFlow (TieMeasureFlow#76).
|
||||||
|
|
||||||
|
Cosa fa: controlla la licenza (prodotto "tiemeasureflow"); se non e' valida mostra il
|
||||||
|
motivo e l'impronta del computer, scrive l'impronta in
|
||||||
|
%PROGRAMDATA%\\Tielogic\\licenze\\impronta-tiemeasureflow.txt ed esce con 1.
|
||||||
|
Se e' valida: prepara la cartella dati (%PROGRAMDATA%\\Tielogic\\TieMeasureFlow, con
|
||||||
|
config.env dei segreti generati al primo avvio), imposta le variabili d'ambiente del
|
||||||
|
contratto, esegue le migrazioni Alembic (SQLite su file) e avvia in un solo processo
|
||||||
|
quattro server, ciascuno nel suo thread:
|
||||||
|
web Flask (src/frontend/flask_app) servito da waitress, --host (default 127.0.0.1) :8780
|
||||||
|
backend FastAPI src.backend.main:app, 127.0.0.1:8000
|
||||||
|
vision FastAPI src.vision_worker.main:app, 127.0.0.1:8100
|
||||||
|
camera scripts/camera_service.py (camere della stazione), 127.0.0.1:8765
|
||||||
|
Poi apre il browser. Se un'altra copia e' gia' in esecuzione apre solo il browser.
|
||||||
|
|
||||||
|
Come si usa: e' l'exe compilato con Nuitka (build.ps1); da sorgente, dalla radice del repo
|
||||||
|
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
|
||||||
|
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.
|
||||||
|
|
||||||
|
⚠️ L'exe e' senza console: stdout/stderr possono essere None, quindi il log va su file
|
||||||
|
(<dati>\\log\\tiemeasureflow.log) e uvicorn gira con log_config=None.
|
||||||
|
⚠️ 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 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
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import ctypes
|
||||||
|
import importlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import socket
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
import webbrowser
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from visionsuite import license as lic # contratto licenza v1: impronta, cerca_licenza, verifica
|
||||||
|
|
||||||
|
PRODOTTO = "tiemeasureflow"
|
||||||
|
TITOLO = "TieMeasureFlow"
|
||||||
|
PORTA_WEB = 8780
|
||||||
|
PORTA_BACKEND = 8000
|
||||||
|
PORTA_VISION = 8100
|
||||||
|
PORTA_CAMERA = 8765
|
||||||
|
log = logging.getLogger("tmf.avvio")
|
||||||
|
|
||||||
|
# Nuitka definisce __compiled__ nel modulo compilato; da sorgente non esiste
|
||||||
|
COMPILATO = "__compiled__" in globals()
|
||||||
|
# Da sorgente (python packaging/windows/avvio.py) la radice e' due livelli sopra questo file
|
||||||
|
RADICE_SORGENTI = Path(__file__).resolve().parents[2]
|
||||||
|
# Segreti generati al primo avvio e riletti dopo (nome -> generatore)
|
||||||
|
SEGRETI = ("SERVER_SECRET_KEY", "CLIENT_SECRET_KEY", "SETUP_PASSWORD", "DB_PASSWORD")
|
||||||
|
# STATION_CODE: senza una stazione l'interfaccia risponde 503; ST-DEFAULT e' quella creata dal seed della pagina di setup
|
||||||
|
PREDEFINITI = {"STATION_CODE": "ST-DEFAULT"}
|
||||||
|
|
||||||
|
|
||||||
|
def _stream_sicuri() -> None:
|
||||||
|
# Exe senza console: stdout/stderr sono None e print/logging/fileConfig di alembic
|
||||||
|
# andrebbero in errore; li si sostituisce con il device nullo
|
||||||
|
if sys.stdout is None:
|
||||||
|
sys.stdout = open(os.devnull, "w", encoding="utf-8")
|
||||||
|
if sys.stderr is None:
|
||||||
|
sys.stderr = open(os.devnull, "w", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def cartella_tielogic() -> Path:
|
||||||
|
# %PROGRAMDATA% e' sempre impostata su Windows; il ripiego serve solo a sviluppo/test su altri sistemi
|
||||||
|
base = os.environ.get("PROGRAMDATA") or str(Path.home() / ".tielogic-dati")
|
||||||
|
return Path(base) / "Tielogic"
|
||||||
|
|
||||||
|
|
||||||
|
def cartella_dati() -> Path:
|
||||||
|
return cartella_tielogic() / "TieMeasureFlow"
|
||||||
|
|
||||||
|
|
||||||
|
def cartella_programma() -> Path:
|
||||||
|
# Cartella dell'exe (P); da sorgente, la radice del repo
|
||||||
|
if COMPILATO:
|
||||||
|
return Path(sys.argv[0]).resolve().parent
|
||||||
|
return RADICE_SORGENTI
|
||||||
|
|
||||||
|
|
||||||
|
def percorso_impronta() -> Path:
|
||||||
|
return cartella_tielogic() / "licenze" / f"impronta-{PRODOTTO}.txt"
|
||||||
|
|
||||||
|
|
||||||
|
def messaggio(testo: str, titolo: str = TITOLO, errore: bool = True, senza_finestre: bool = False) -> None:
|
||||||
|
# Sempre su stderr (se esiste); la MessageBox solo su Windows e se non soppressa
|
||||||
|
if sys.stderr is not None:
|
||||||
|
print(testo, file=sys.stderr)
|
||||||
|
if senza_finestre or sys.platform != "win32":
|
||||||
|
return
|
||||||
|
# MB_OK | MB_ICONERROR (0x10) oppure MB_ICONINFORMATION (0x40) | MB_TOPMOST (0x40000)
|
||||||
|
ctypes.windll.user32.MessageBoxW(None, testo, titolo, 0x40000 | (0x10 if errore else 0x40))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- licenza
|
||||||
|
|
||||||
|
def _scrivi_impronta(impronta: str) -> None:
|
||||||
|
try:
|
||||||
|
p = percorso_impronta()
|
||||||
|
p.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
p.write_text(impronta + "\n", encoding="utf-8")
|
||||||
|
except OSError as e:
|
||||||
|
log.warning("impronta non scritta: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
def controlla_licenza(senza_finestre: bool = False) -> bool:
|
||||||
|
"""True se la licenza e' valida; altrimenti mostra il motivo e l'impronta e torna False."""
|
||||||
|
try:
|
||||||
|
percorso = lic.cerca_licenza(PRODOTTO)
|
||||||
|
lic.verifica(percorso, PRODOTTO)
|
||||||
|
return True
|
||||||
|
except lic.LicenzaNonValida as e:
|
||||||
|
motivo = str(e)
|
||||||
|
except Exception as e: # impronta non calcolabile, file illeggibile: mai un'eccezione muta nell'exe
|
||||||
|
motivo = f"Controllo licenza non riuscito: {e}"
|
||||||
|
try:
|
||||||
|
impronta = lic.impronta()
|
||||||
|
except Exception as e:
|
||||||
|
impronta = None
|
||||||
|
motivo += f"\nImpronta macchina non disponibile: {e}"
|
||||||
|
testo = f"{motivo}\n\n"
|
||||||
|
if impronta:
|
||||||
|
_scrivi_impronta(impronta)
|
||||||
|
testo += (f"Impronta di questo computer:\n{impronta}\n\n"
|
||||||
|
f"Comunicala a Tielogic per ottenere la licenza (salvata anche in {percorso_impronta()}). "
|
||||||
|
f"La licenza va copiata in {lic.cerca_licenza(PRODOTTO)}.")
|
||||||
|
messaggio(testo, f"{TITOLO} - licenza", senza_finestre=senza_finestre)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- configurazione
|
||||||
|
|
||||||
|
def _leggi_env(percorso: Path) -> dict[str, str]:
|
||||||
|
valori: dict[str, str] = {}
|
||||||
|
for riga in percorso.read_text(encoding="utf-8").splitlines():
|
||||||
|
riga = riga.strip()
|
||||||
|
if riga and not riga.startswith("#") and "=" in riga:
|
||||||
|
k, v = riga.split("=", 1)
|
||||||
|
valori[k.strip()] = v.strip()
|
||||||
|
return valori
|
||||||
|
|
||||||
|
|
||||||
|
def carica_segreti(dati: Path) -> dict[str, str]:
|
||||||
|
"""Legge <dati>\\config.env; i segreti mancanti si generano e si riscrivono (una volta sola)."""
|
||||||
|
dati.mkdir(parents=True, exist_ok=True)
|
||||||
|
percorso = dati / "config.env"
|
||||||
|
valori = _leggi_env(percorso) if percorso.exists() else {}
|
||||||
|
nuovi = [k for k in SEGRETI if not valori.get(k)]
|
||||||
|
# valori di partenza modificabili dall'utente in config.env (scritti una volta sola)
|
||||||
|
mancanti = [k for k in PREDEFINITI if k not in valori]
|
||||||
|
if nuovi or mancanti:
|
||||||
|
for k in nuovi:
|
||||||
|
valori[k] = secrets.token_urlsafe(32)
|
||||||
|
for k in mancanti:
|
||||||
|
valori[k] = PREDEFINITI[k]
|
||||||
|
righe = ["# Generato da TieMeasureFlow al primo avvio. Non condividere questo file.",
|
||||||
|
"# SETUP_PASSWORD serve per la pagina http://127.0.0.1:8000/api/setup (primo amministratore)."]
|
||||||
|
righe += [f"{k}={v}" for k, v in valori.items()]
|
||||||
|
percorso.write_text("\n".join(righe) + "\n", encoding="utf-8")
|
||||||
|
log.info("segreti generati in %s", percorso)
|
||||||
|
return valori
|
||||||
|
|
||||||
|
|
||||||
|
def trova_browser() -> str | None:
|
||||||
|
"""Chrome o Edge per kaleido (grafici dei report PDF, offline)."""
|
||||||
|
pf = os.environ.get("ProgramFiles", r"C:\Program Files")
|
||||||
|
pf86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
|
||||||
|
local = os.environ.get("LOCALAPPDATA", "")
|
||||||
|
candidati = [
|
||||||
|
Path(pf) / "Google/Chrome/Application/chrome.exe",
|
||||||
|
Path(pf86) / "Google/Chrome/Application/chrome.exe",
|
||||||
|
Path(local) / "Google/Chrome/Application/chrome.exe" if local else None,
|
||||||
|
Path(pf86) / "Microsoft/Edge/Application/msedge.exe",
|
||||||
|
Path(pf) / "Microsoft/Edge/Application/msedge.exe",
|
||||||
|
]
|
||||||
|
for c in candidati:
|
||||||
|
if c is not None and c.is_file():
|
||||||
|
return str(c)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def imposta_ambiente(dati: Path, host: str, valori: dict[str, str]) -> None:
|
||||||
|
"""Variabili d'ambiente del contratto v1; da chiamare PRIMA di importare backend e frontend."""
|
||||||
|
uploads = dati / "uploads"
|
||||||
|
env = {
|
||||||
|
"APP_ENV": "production",
|
||||||
|
# barre "/" nel percorso: l'URL SQLAlchemy non vuole le barre rovesciate
|
||||||
|
"DB_URL": f"sqlite+aiosqlite:///{(dati / 'tmf.db').as_posix()}",
|
||||||
|
"UPLOAD_DIR": str(uploads),
|
||||||
|
"VISION_WORKER_URL": f"http://127.0.0.1:{PORTA_VISION}",
|
||||||
|
"VISION_RECIPES_ROOT": str(uploads),
|
||||||
|
"API_SERVER_URL": f"http://127.0.0.1:{PORTA_BACKEND}",
|
||||||
|
"SESSION_COOKIE_SECURE": "0", # niente HTTPS in locale: un cookie Secure verrebbe scartato
|
||||||
|
"SERVER_CORS_ORIGINS": f"http://127.0.0.1:{PORTA_WEB},http://localhost:{PORTA_WEB}",
|
||||||
|
**{k: valori[k] for k in SEGRETI},
|
||||||
|
}
|
||||||
|
# le altre chiavi di config.env (STATION_CODE, STATION_SWITCH_ENABLED, ...) passano cosi' come sono,
|
||||||
|
# ma non sovrascrivono le variabili fisse del contratto
|
||||||
|
for k, v in valori.items():
|
||||||
|
env.setdefault(k, v)
|
||||||
|
programma = cartella_programma()
|
||||||
|
versione = programma / "vision-engine-version.txt" # scritto da build.ps1
|
||||||
|
if versione.is_file():
|
||||||
|
env["VISION_ENGINE_VERSION"] = versione.read_text(encoding="utf-8").strip()
|
||||||
|
gtk = programma / "gtk" # DLL di WeasyPrint (piano B)
|
||||||
|
if gtk.is_dir():
|
||||||
|
env["WEASYPRINT_DLL_DIRECTORIES"] = str(gtk)
|
||||||
|
if hasattr(os, "add_dll_directory"):
|
||||||
|
# cintura e bretelle: WeasyPrint salta add_dll_directory se trova sys.frozen
|
||||||
|
try:
|
||||||
|
os.add_dll_directory(str(gtk))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
browser = trova_browser()
|
||||||
|
if browser:
|
||||||
|
env["BROWSER_PATH"] = browser
|
||||||
|
os.environ.update(env)
|
||||||
|
for cartella in (uploads, dati / "camera", dati / "log"):
|
||||||
|
cartella.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _prepara_percorsi_sorgenti() -> None:
|
||||||
|
# Da sorgente i moduli del frontend (config, services, blueprints, app) e lo script camera
|
||||||
|
# non sono pacchetti: si aggiungono al path. Compilati, Nuitka li ha gia' incorporati.
|
||||||
|
if COMPILATO:
|
||||||
|
return
|
||||||
|
for p in (RADICE_SORGENTI / "scripts", RADICE_SORGENTI / "src" / "frontend" / "flask_app", RADICE_SORGENTI):
|
||||||
|
if str(p) not in sys.path:
|
||||||
|
sys.path.insert(0, str(p))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- log
|
||||||
|
|
||||||
|
def _prepara_log(dati: Path | None = None) -> None:
|
||||||
|
# Exe senza console: il log va su file; se la cartella non e' scrivibile si prosegue senza.
|
||||||
|
# Idempotente: alembic (fileConfig in env.py) azzera i gestori del root, quindi si richiama dopo.
|
||||||
|
try:
|
||||||
|
d = (dati or cartella_dati()) / "log"
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
percorso = str(d / "tiemeasureflow.log")
|
||||||
|
root = logging.getLogger()
|
||||||
|
for h in root.handlers:
|
||||||
|
if isinstance(h, logging.FileHandler) and h.baseFilename.lower() == os.path.abspath(percorso).lower():
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
h = logging.FileHandler(percorso, encoding="utf-8")
|
||||||
|
h.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
|
||||||
|
root.addHandler(h)
|
||||||
|
root.setLevel(logging.INFO)
|
||||||
|
for nome in ("tmf.avvio",):
|
||||||
|
logging.getLogger(nome).disabled = False # fileConfig disattiva i logger esistenti
|
||||||
|
except OSError:
|
||||||
|
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
|
||||||
|
|
||||||
|
def _porta_aperta(porta: int) -> bool:
|
||||||
|
try:
|
||||||
|
with socket.create_connection(("127.0.0.1", porta), timeout=0.5):
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _e_tmf() -> bool:
|
||||||
|
# Una copia gia' in esecuzione: il backend risponde su /api/health con il nome del servizio
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(f"http://127.0.0.1:{PORTA_BACKEND}/api/health", timeout=2) as r:
|
||||||
|
return "TieMeasureFlow" in str(json.load(r).get("service", ""))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _nessun_utente(db: Path) -> bool:
|
||||||
|
"""True se il database esiste ed e' senza utenti (primo avvio: serve la pagina di setup)."""
|
||||||
|
try:
|
||||||
|
con = sqlite3.connect(f"file:{db.as_posix()}?mode=ro", uri=True, timeout=2)
|
||||||
|
try:
|
||||||
|
return con.execute("SELECT COUNT(*) FROM users").fetchone()[0] == 0
|
||||||
|
finally:
|
||||||
|
con.close()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _apri_browser_quando_pronto(url: str, porte: tuple[int, ...]) -> None:
|
||||||
|
# Aspetta che i server ascoltino (max ~60 s), poi apre il browser predefinito
|
||||||
|
for _ in range(300):
|
||||||
|
if all(_porta_aperta(p) for p in porte):
|
||||||
|
break
|
||||||
|
time.sleep(0.2)
|
||||||
|
webbrowser.open(url)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- migrazioni
|
||||||
|
|
||||||
|
def _neutralizza_fileconfig() -> None:
|
||||||
|
# env.py di Alembic chiama logging.config.fileConfig (anche la pagina di setup, con command.stamp):
|
||||||
|
# azzererebbe i gestori del log su file. Nel pacchetto il log lo decide avvio.py.
|
||||||
|
import logging.config
|
||||||
|
logging.config.fileConfig = lambda *a, **k: None
|
||||||
|
|
||||||
|
|
||||||
|
def esegui_migrazioni() -> None:
|
||||||
|
"""alembic upgrade head. Compilato: env.py e versions\\ sono soli .pyc, quindi sourceless."""
|
||||||
|
_neutralizza_fileconfig()
|
||||||
|
from alembic import command
|
||||||
|
from alembic.config import Config
|
||||||
|
import src.backend as pacchetto
|
||||||
|
migrazioni = Path(pacchetto.__file__).resolve().parent / "migrations"
|
||||||
|
cfg = Config(str(migrazioni / "alembic.ini"))
|
||||||
|
if COMPILATO:
|
||||||
|
cfg.set_main_option("sourceless", "true")
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- server
|
||||||
|
|
||||||
|
class ServerThread(threading.Thread):
|
||||||
|
"""Un server (uvicorn o waitress) in un thread; `errore` resta valorizzato se muore."""
|
||||||
|
|
||||||
|
def __init__(self, nome: str, costruisci):
|
||||||
|
super().__init__(name=nome, daemon=True)
|
||||||
|
self._costruisci = costruisci
|
||||||
|
self.server = None
|
||||||
|
self.errore: BaseException | None = None
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
try:
|
||||||
|
self.server = self._costruisci()
|
||||||
|
self.server.run()
|
||||||
|
except BaseException as e: # un server morto deve comparire nel log e fermare l'insieme
|
||||||
|
self.errore = e
|
||||||
|
log.exception("server %s terminato con errore", self.name)
|
||||||
|
|
||||||
|
def ferma(self) -> None:
|
||||||
|
s = self.server
|
||||||
|
try:
|
||||||
|
if hasattr(s, "should_exit"):
|
||||||
|
s.should_exit = True # uvicorn
|
||||||
|
elif s is not None:
|
||||||
|
s.close() # waitress
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _uvicorn(app, porta: int):
|
||||||
|
import uvicorn
|
||||||
|
# log_config=None: niente console; i thread non toccano i signal handler (uvicorn lo gestisce)
|
||||||
|
return uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=porta, log_config=None, workers=1))
|
||||||
|
|
||||||
|
|
||||||
|
def costruisci_web(host: str):
|
||||||
|
import waitress
|
||||||
|
from app import create_app # src/frontend/flask_app/app.py
|
||||||
|
return waitress.create_server(create_app(), host=host, port=PORTA_WEB, threads=8)
|
||||||
|
|
||||||
|
|
||||||
|
def costruisci_backend():
|
||||||
|
from src.backend.main import app
|
||||||
|
return _uvicorn(app, PORTA_BACKEND)
|
||||||
|
|
||||||
|
|
||||||
|
def costruisci_vision():
|
||||||
|
from src.vision_worker.main import app
|
||||||
|
return _uvicorn(app, PORTA_VISION)
|
||||||
|
|
||||||
|
|
||||||
|
def costruisci_camera(cartella: Path):
|
||||||
|
from camera_service import create_app # scripts/camera_service.py (non e' un pacchetto)
|
||||||
|
return _uvicorn(create_app(cartella), PORTA_CAMERA)
|
||||||
|
|
||||||
|
|
||||||
|
def avvia_server(host: str, dati: Path) -> list[ServerThread]:
|
||||||
|
thread = [
|
||||||
|
ServerThread("backend", costruisci_backend),
|
||||||
|
ServerThread("vision", costruisci_vision),
|
||||||
|
ServerThread("camera", lambda: costruisci_camera(dati / "camera")),
|
||||||
|
ServerThread("web", lambda: costruisci_web(host)),
|
||||||
|
]
|
||||||
|
for t in thread:
|
||||||
|
t.start()
|
||||||
|
return thread
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None) -> int:
|
||||||
|
_stream_sicuri()
|
||||||
|
p = argparse.ArgumentParser(prog="TieMeasureFlow", description=TITOLO)
|
||||||
|
p.add_argument("--impronta", action="store_true", help="mostra l'impronta di questo computer")
|
||||||
|
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-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)
|
||||||
|
_prepara_log()
|
||||||
|
|
||||||
|
if args.impronta:
|
||||||
|
try:
|
||||||
|
impronta = lic.impronta()
|
||||||
|
except Exception as e:
|
||||||
|
messaggio(f"Impronta non disponibile: {e}", senza_finestre=args.senza_finestre)
|
||||||
|
return 1
|
||||||
|
print(impronta) if sys.stdout is not None else None
|
||||||
|
messaggio(f"Impronta di questo computer:\n{impronta}", errore=False, senza_finestre=args.senza_finestre)
|
||||||
|
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):
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if _porta_aperta(PORTA_WEB):
|
||||||
|
if _e_tmf():
|
||||||
|
# TieMeasureFlow e' gia' in esecuzione: basta riaprire la pagina
|
||||||
|
if not args.senza_browser:
|
||||||
|
webbrowser.open(f"http://127.0.0.1:{PORTA_WEB}/")
|
||||||
|
return 0
|
||||||
|
messaggio(f"La porta {PORTA_WEB} e' occupata da un altro programma: chiuderlo e riprovare.",
|
||||||
|
senza_finestre=args.senza_finestre)
|
||||||
|
return 2
|
||||||
|
occupate = [str(x) for x in (PORTA_BACKEND, PORTA_VISION, PORTA_CAMERA) if _porta_aperta(x)]
|
||||||
|
if occupate:
|
||||||
|
messaggio(f"Porte occupate da altri programmi: {', '.join(occupate)}. Chiuderli e riprovare.",
|
||||||
|
senza_finestre=args.senza_finestre)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
dati = cartella_dati()
|
||||||
|
try:
|
||||||
|
valori = carica_segreti(dati)
|
||||||
|
imposta_ambiente(dati, args.host, valori)
|
||||||
|
_prepara_percorsi_sorgenti()
|
||||||
|
primo_avvio = not (dati / "tmf.db").exists()
|
||||||
|
esegui_migrazioni()
|
||||||
|
except Exception as e:
|
||||||
|
_prepara_log(dati)
|
||||||
|
log.exception("avvio non riuscito")
|
||||||
|
messaggio(f"Avvio non riuscito: {e}\nDettagli in {dati / 'log' / 'tiemeasureflow.log'}",
|
||||||
|
senza_finestre=args.senza_finestre)
|
||||||
|
return 3
|
||||||
|
_prepara_log(dati) # alembic ha riconfigurato il logging
|
||||||
|
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)
|
||||||
|
if not args.senza_browser:
|
||||||
|
# database senza utenti: si apre la pagina di setup del backend (SETUP_PASSWORD in config.env)
|
||||||
|
url = (f"http://127.0.0.1:{PORTA_BACKEND}/api/setup" if _nessun_utente(dati / "tmf.db")
|
||||||
|
else f"http://127.0.0.1:{PORTA_WEB}/")
|
||||||
|
threading.Thread(target=_apri_browser_quando_pronto,
|
||||||
|
args=(url, (PORTA_WEB, PORTA_BACKEND)), daemon=True).start()
|
||||||
|
|
||||||
|
# Si resta vivi finche' tutti i server girano; se uno muore si ferma tutto
|
||||||
|
try:
|
||||||
|
while thread and all(t.is_alive() for t in thread):
|
||||||
|
time.sleep(1)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
morti = [t for t in thread if not t.is_alive()]
|
||||||
|
for t in thread:
|
||||||
|
t.ferma()
|
||||||
|
if morti:
|
||||||
|
messaggio("Un componente si e' fermato: " + ", ".join(f"{t.name} ({t.errore})" for t in morti)
|
||||||
|
+ f"\nDettagli in {dati / 'log' / 'tiemeasureflow.log'}", senza_finestre=args.senza_finestre)
|
||||||
|
return 4
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
<#
|
||||||
|
Pacchetto Windows di TieMeasureFlow (TieMeasureFlow#76).
|
||||||
|
|
||||||
|
Cosa fa: uv sync in un ambiente Python 3.13 separato (build\venv313), frontend (Tailwind +
|
||||||
|
traduzioni), compila avvio.py con Nuitka in modalita' --standalone (cartella, NON onefile:
|
||||||
|
l'onefile si decomprime in %TEMP% ad ogni avvio), aggiunge le migrazioni Alembic come soli
|
||||||
|
.pyc, le DLL di WeasyPrint (piano B) e il commit del motore di visione, poi Inno Setup
|
||||||
|
produce dist\TieMeasureFlow-setup-<versione>.exe. Sul PC del cliente non finisce nessun
|
||||||
|
.py; i .pyc ci sono solo in src\backend\migrations (Alembic le carica dal disco).
|
||||||
|
|
||||||
|
Come si usa (dalla radice del repo, con il submodule vendor/visionsuite inizializzato):
|
||||||
|
powershell -ExecutionPolicy Bypass -File packaging\windows\build.ps1 [-Versione 3.0.0]
|
||||||
|
-Versione accetta anche un tag (v3.0.0, v3.0.0-rc1): si usano i numeri iniziali.
|
||||||
|
Senza -Versione si usa la variabile GITHUB_REF_NAME (tag nella CI) o 0.0.0.
|
||||||
|
-SenzaSetup si ferma dopo Nuitka (cartella build\nuitka\avvio.dist).
|
||||||
|
|
||||||
|
Dipende da: uv, node/npx (Tailwind 3.4.19 da npm: serve Internet SOLO a chi costruisce),
|
||||||
|
Visual Studio con carico C++ (MSVC; Nuitka lo trova da solo), Inno Setup 6
|
||||||
|
(`winget install --id JRSoftware.InnoSetup -e`; ISCC.exe nel PATH, in Program Files o in
|
||||||
|
%LOCALAPPDATA%\Programs). Nuitka e' nel gruppo uv "windows" (pyproject.toml).
|
||||||
|
|
||||||
|
⚠️ La prima compilazione e' lunga (decine di minuti: numpy/opencv/scipy/plotly); le successive
|
||||||
|
riusano la cache di Nuitka (%LOCALAPPDATA%\Nuitka) e la cartella build\nuitka.
|
||||||
|
⚠️ I pacchetti di Core condividono il namespace visionsuite (extend_path): Nuitka non lo
|
||||||
|
segue da solo, quindi i sottopacchetti si elencano uno per uno dagli __path__ installati.
|
||||||
|
⚠️ 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):
|
||||||
|
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`):
|
||||||
|
si compila mettendo flask_app e scripts su PYTHONPATH, come fa avvio.py in sviluppo.
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[string]$Versione = $env:GITHUB_REF_NAME,
|
||||||
|
[switch]$SenzaSetup
|
||||||
|
)
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$radice = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||||
|
Set-Location $radice
|
||||||
|
|
||||||
|
# Versione numerica a 4 parti per i metadati dell'exe (Nuitka la vuole cosi')
|
||||||
|
if (-not $Versione) { $Versione = '0.0.0' }
|
||||||
|
if ($Versione -notmatch '^v?(\d+)\.(\d+)\.(\d+)') { throw "Versione non valida: '$Versione' (atteso es. v3.0.0)" }
|
||||||
|
$ver3 = "$($Matches[1]).$($Matches[2]).$($Matches[3])"
|
||||||
|
$ver4 = "$ver3.0"
|
||||||
|
Write-Host "== TieMeasureFlow $ver3 =="
|
||||||
|
|
||||||
|
# Controlli: se manca qualcosa il messaggio dice come installarlo
|
||||||
|
if (-not (Get-Command uv -ErrorAction SilentlyContinue)) {
|
||||||
|
throw "uv non trovato. Installarlo: https://docs.astral.sh/uv/getting-started/installation/"
|
||||||
|
}
|
||||||
|
if (-not (Get-Command npx -ErrorAction SilentlyContinue)) {
|
||||||
|
throw "node/npx non trovato (serve a compilare Tailwind). Installarlo da https://nodejs.org"
|
||||||
|
}
|
||||||
|
if (-not (Test-Path (Join-Path $radice 'vendor\visionsuite\packages\vs-license'))) {
|
||||||
|
throw "Submodule vendor/visionsuite mancante o vecchio: git submodule update --init --recursive"
|
||||||
|
}
|
||||||
|
$iscc = (Get-Command ISCC.exe -ErrorAction SilentlyContinue).Source
|
||||||
|
if (-not $iscc) {
|
||||||
|
foreach ($c in @("${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe", "$env:ProgramFiles\Inno Setup 6\ISCC.exe",
|
||||||
|
"$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe")) {
|
||||||
|
if (Test-Path $c) { $iscc = $c; break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (-not $iscc -and -not $SenzaSetup) { throw "Inno Setup 6 non trovato. Installarlo: winget install --id JRSoftware.InnoSetup -e" }
|
||||||
|
|
||||||
|
# Ambiente separato da quello di sviluppo (3.11): extra server + client + vision-worker + gruppo windows
|
||||||
|
$env:UV_PROJECT_ENVIRONMENT = Join-Path $radice 'build\venv313'
|
||||||
|
uv sync --python 3.13 --frozen --extra server --extra client --extra vision-worker --group windows
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "uv sync fallito" }
|
||||||
|
$py = Join-Path $env:UV_PROJECT_ENVIRONMENT 'Scripts\python.exe'
|
||||||
|
$vn = & $py -m nuitka --version # niente Select-Object -First: chiude la pipe e falsa l'exit code
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Nuitka non disponibile (serve anche un compilatore C: Visual Studio con 'Sviluppo di applicazioni desktop con C++')" }
|
||||||
|
|
||||||
|
# Frontend: tailwind.css non e' nel repo (stessa versione esatta di Dockerfile.frontend) e traduzioni .mo
|
||||||
|
$flask = Join-Path $radice 'src\frontend\flask_app'
|
||||||
|
Push-Location $flask
|
||||||
|
try {
|
||||||
|
npx --yes tailwindcss@3.4.19 -i static/css/input.css -o static/css/tailwind.css --minify
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Tailwind fallito" }
|
||||||
|
& (Join-Path $env:UV_PROJECT_ENVIRONMENT 'Scripts\pybabel.exe') compile -d translations
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "pybabel compile fallito" }
|
||||||
|
} finally { Pop-Location }
|
||||||
|
|
||||||
|
# Sottopacchetti del namespace visionsuite (core, camera, task, tools, license, ...)
|
||||||
|
$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" }
|
||||||
|
$includi = @($pacchetti -split "\r?\n" | Where-Object { $_ } | ForEach-Object { "--include-package=$_" })
|
||||||
|
# Nessun --include-distribution-metadata per vs-*: Nuitka rifiuta i metadati di una distribuzione il cui
|
||||||
|
# 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 ' ')"
|
||||||
|
|
||||||
|
# Flag Nuitka del piano PDF (WeasyPrint, kaleido, pyphen, plotly, tzdata...): le righe "--include-..."
|
||||||
|
# del LEGGIMI.md, con l'eventuale commento "# ..." in coda. I dati di src/backend/templates sono gia' sopra.
|
||||||
|
$flagPdf = @()
|
||||||
|
$leggimi = Join-Path $PSScriptRoot 'pdf\LEGGIMI.md'
|
||||||
|
if (Test-Path $leggimi) {
|
||||||
|
$flagPdf = @(Get-Content $leggimi | ForEach-Object {
|
||||||
|
if ($_ -match '^\s*(--(?:include|nofollow|noinclude|enable)[^\s`#]*)\s*(?:#.*)?$' -and $Matches[1] -notlike '--include-data-dir=src/backend/templates*') { $Matches[1] }
|
||||||
|
})
|
||||||
|
Write-Host "Flag Nuitka da pdf\LEGGIMI.md: $($flagPdf -join ' ')"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Nuitka cerca i moduli non-pacchetto (config, services, blueprints, app, camera_service) su PYTHONPATH
|
||||||
|
$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'
|
||||||
|
New-Item -ItemType Directory -Force $out | Out-Null
|
||||||
|
$nuitka = @(
|
||||||
|
'--standalone',
|
||||||
|
'--assume-yes-for-downloads',
|
||||||
|
'--msvc=latest',
|
||||||
|
'--windows-console-mode=disable', # nessuna finestra nera: gli errori escono in MessageBox e nel log
|
||||||
|
"--output-dir=$out",
|
||||||
|
'--output-filename=TieMeasureFlow.exe',
|
||||||
|
'--windows-icon-from-ico=packaging\windows\TieMeasureFlow.ico',
|
||||||
|
'--company-name=Tielogic',
|
||||||
|
'--product-name=TieMeasureFlow',
|
||||||
|
"--file-version=$ver4",
|
||||||
|
"--product-version=$ver4",
|
||||||
|
'--file-description=TieMeasureFlow',
|
||||||
|
'--copyright=Tielogic',
|
||||||
|
# codice del prodotto: backend, worker di visione, runner (src e' un pacchetto, flask_app no)
|
||||||
|
'--include-package=src.backend',
|
||||||
|
'--include-package=src.vision',
|
||||||
|
'--include-package=src.vision_worker',
|
||||||
|
# le migrazioni NON si compilano: Alembic le carica dal disco, vanno come .pyc (sotto)
|
||||||
|
'--nofollow-import-to=src.backend.migrations',
|
||||||
|
'--nofollow-import-to=src.backend.tests',
|
||||||
|
'--nofollow-import-to=src.vision.tests',
|
||||||
|
'--nofollow-import-to=src.vision_worker.tests',
|
||||||
|
# frontend Flask e servizio camera: moduli di primo livello trovati via PYTHONPATH
|
||||||
|
'--include-module=app',
|
||||||
|
'--include-module=config',
|
||||||
|
'--include-package=blueprints',
|
||||||
|
'--include-package=services',
|
||||||
|
'--include-module=camera_service',
|
||||||
|
'--nofollow-import-to=blueprints.tests',
|
||||||
|
'--nofollow-import-to=tests',
|
||||||
|
'--include-package=uvicorn', # loop e protocolli di uvicorn si caricano per nome
|
||||||
|
'--include-package=waitress',
|
||||||
|
'--include-package=alembic',
|
||||||
|
'--include-module=aiosqlite',
|
||||||
|
'--include-module=sqlalchemy.dialects.sqlite.aiosqlite',
|
||||||
|
'--include-package=harvesters', # camere GenTL (GigE): il producer .cti e' del vendor
|
||||||
|
'--include-package=genicam',
|
||||||
|
'--include-package-data=genicam',
|
||||||
|
'--include-package-data=babel', # locale-data di Babel (flask-babel)
|
||||||
|
'--nofollow-import-to=numba', # vedi nota in testa: il ripiego in Python puro
|
||||||
|
'--nofollow-import-to=llvmlite',
|
||||||
|
# dati letti dal disco con __file__: setup page, report, Flask (templates/static/translations alla radice)
|
||||||
|
'--include-data-dir=src\backend\templates=src\backend\templates',
|
||||||
|
'--include-data-files=src\backend\migrations\alembic.ini=src\backend\migrations\alembic.ini',
|
||||||
|
'--include-data-dir=src\frontend\flask_app\templates=templates',
|
||||||
|
'--include-data-dir=src\frontend\flask_app\static=static',
|
||||||
|
'--include-data-dir=src\frontend\flask_app\translations=translations',
|
||||||
|
'--include-data-files=pyproject.toml=pyproject.toml' # config.py ne legge la versione per il piede di pagina
|
||||||
|
) + $includi + $flagPdf + @('packaging\windows\avvio.py')
|
||||||
|
|
||||||
|
$t0 = Get-Date
|
||||||
|
& $py -m nuitka @nuitka
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Nuitka fallito (codice $LASTEXITCODE)" }
|
||||||
|
Write-Host ("Nuitka: {0:N0} minuti" -f ((Get-Date) - $t0).TotalMinutes)
|
||||||
|
|
||||||
|
$sorgente = Join-Path $out 'avvio.dist'
|
||||||
|
if (-not (Test-Path (Join-Path $sorgente 'TieMeasureFlow.exe'))) { throw "exe non trovato in $sorgente" }
|
||||||
|
|
||||||
|
# Migrazioni Alembic: env.py e versions\ come soli .pyc (compileall -b: .pyc accanto al sorgente, che si cancella)
|
||||||
|
$mig = Join-Path $sorgente 'src\backend\migrations'
|
||||||
|
New-Item -ItemType Directory -Force (Join-Path $mig 'versions') | Out-Null
|
||||||
|
Copy-Item 'src\backend\migrations\env.py' $mig -Force
|
||||||
|
Copy-Item 'src\backend\migrations\versions\*.py' (Join-Path $mig 'versions') -Force
|
||||||
|
& $py -m compileall -b -q $mig
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "compileall delle migrazioni fallito" }
|
||||||
|
Get-ChildItem $mig -Recurse -Filter *.py -File | Remove-Item -Force
|
||||||
|
Get-ChildItem $mig -Recurse -Directory -Filter __pycache__ | Remove-Item -Recurse -Force
|
||||||
|
|
||||||
|
# alembic.ini del pacchetto: sourceless, perche' anche la pagina di setup (command.stamp) apre
|
||||||
|
# alembic.ini con un Config suo e non troverebbe env.py/versions (solo .pyc)
|
||||||
|
$ini = Join-Path $mig 'alembic.ini'
|
||||||
|
$testoIni = [System.IO.File]::ReadAllText($ini)
|
||||||
|
if ($testoIni -notmatch '(?m)^script_location') { throw "alembic.ini senza script_location" }
|
||||||
|
$testoIni = $testoIni -replace '(?m)^(script_location[^\r\n]*)', "`$1`nsourceless = true"
|
||||||
|
[System.IO.File]::WriteAllText($ini, $testoIni, (New-Object System.Text.UTF8Encoding $false))
|
||||||
|
|
||||||
|
# Commit del motore di visione: avvio.py lo legge in VISION_ENGINE_VERSION
|
||||||
|
$motore = (git -C vendor/visionsuite rev-parse HEAD).Trim()
|
||||||
|
if ($LASTEXITCODE -ne 0 -or -not $motore) { throw "Commit di vendor/visionsuite non leggibile" }
|
||||||
|
[System.IO.File]::WriteAllText((Join-Path $sorgente 'vision-engine-version.txt'), $motore, (New-Object System.Text.UTF8Encoding $false))
|
||||||
|
|
||||||
|
# DLL di WeasyPrint (piano PDF): lo script le scarica con sha256 pinnato
|
||||||
|
$gtk = Join-Path $PSScriptRoot 'pdf\prendi-gtk.ps1'
|
||||||
|
if (Test-Path $gtk) {
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File $gtk -Destinazione (Join-Path $sorgente 'gtk')
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "prendi-gtk.ps1 fallito" }
|
||||||
|
} else {
|
||||||
|
Write-Warning "packaging\windows\pdf\prendi-gtk.ps1 assente: il pacchetto non avra' le DLL di WeasyPrint (niente report PDF)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Il pacchetto non deve contenere codice sorgente o bytecode, tranne i .pyc delle migrazioni
|
||||||
|
$sporchi = Get-ChildItem $sorgente -Recurse -Include *.py, *.pyc -File |
|
||||||
|
Where-Object { $_.FullName -notlike "$mig\*" -or $_.Extension -eq '.py' }
|
||||||
|
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 }
|
||||||
|
|
||||||
|
$dist = Join-Path $radice 'dist'
|
||||||
|
New-Item -ItemType Directory -Force $dist | Out-Null
|
||||||
|
& $iscc "/DVersione=$ver3" "/DVersione4=$ver4" "/DOrigine=$sorgente" "/DUscita=$dist" (Join-Path $radice 'packaging\windows\TieMeasureFlow.iss')
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Inno Setup fallito" }
|
||||||
|
|
||||||
|
$setup = Join-Path $dist "TieMeasureFlow-setup-$ver3.exe"
|
||||||
|
Write-Host ("Pronto: {0} ({1:N1} MB)" -f $setup, ((Get-Item $setup).Length / 1MB))
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Le DLL scaricate da prendi-gtk.ps1 non si committano: solo lo script (TieMeasureFlow#78).
|
||||||
|
gtk/
|
||||||
|
cache/
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# Report PDF nel pacchetto Windows
|
||||||
|
|
||||||
|
Questa cartella contiene quello che serve perché i report PDF di TieMeasureFlow
|
||||||
|
(`src/backend/services/report_service.py`: Jinja2, Plotly con Kaleido 1.2.0, WeasyPrint 68.1)
|
||||||
|
funzionino su Windows, dentro il pacchetto, **senza Internet**. Riferimento: TieMeasureFlow#78
|
||||||
|
(parte del pacchetto Windows, madre #76).
|
||||||
|
|
||||||
|
## Come funziona
|
||||||
|
|
||||||
|
Un report SPC passa da due componenti nativi che su Windows non arrivano con `pip`:
|
||||||
|
|
||||||
|
1. **WeasyPrint** disegna il PDF e carica a runtime le DLL di Pango/GTK
|
||||||
|
(`libgobject-2.0-0`, `libpango-1.0-0`, `libharfbuzz-0`, `libharfbuzz-subset-0`,
|
||||||
|
`libfontconfig-1`, `libpangoft2-1.0-0` e le loro dipendenze).
|
||||||
|
2. **Kaleido 1.x** trasforma i grafici Plotly in SVG pilotando un browser Chromium già installato
|
||||||
|
(non porta più Chromium con sé). Su ogni Windows 10/11 c'è Edge.
|
||||||
|
|
||||||
|
### Le DLL: `prendi-gtk.ps1`
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File packaging\windows\pdf\prendi-gtk.ps1 -Destinazione <cartella>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Scarica da `repo.msys2.org` (ucrt64) 21 pacchetti **pinnati con sha256** (nome file e hash stanno nello
|
||||||
|
script), verifica l'hash, estrae solo le 27 DLL necessarie, `etc\fonts` di fontconfig e le licenze.
|
||||||
|
- Idempotente: se la cartella è già pronta (file `.gtk-pin` uguale ai pin dello script) esce subito.
|
||||||
|
- Se un hash non coincide fallisce e non lascia mezza cartella.
|
||||||
|
- I pacchetti scaricati stanno in `%LOCALAPPDATA%\Tielogic\tmf-gtk-cache` (`-Cache` per cambiarla).
|
||||||
|
- Serve Internet **solo alla build**. Estrae `.zst` con `tar.exe` di Windows (verificato: bsdtar 3.8.9 con
|
||||||
|
zstd); se `tar.exe` non sa di zstd ripiega su `uv run --with zstandard`.
|
||||||
|
- Gira da utente non admin con PowerShell 5.1.
|
||||||
|
- ⚠️ `repo.msys2.org` tiene solo le versioni recenti: se un file pinnato sparisce (404) vanno aggiornati nome
|
||||||
|
e sha256 dal db `https://repo.msys2.org/mingw/ucrt64/ucrt64.db` e rifatto il test. Conservare la cache
|
||||||
|
(Wasabi o NAS) evita la corsa contro il repository.
|
||||||
|
|
||||||
|
Il piano C copia `<cartella>` in `P\gtk\` (P = cartella programma).
|
||||||
|
|
||||||
|
### Variabili d'ambiente (le imposta `avvio.py`, piano C)
|
||||||
|
|
||||||
|
| Variabile | Valore | Perché |
|
||||||
|
|---|---|---|
|
||||||
|
| `WEASYPRINT_DLL_DIRECTORIES` | `<P>\gtk` | WeasyPrint chiama `os.add_dll_directory` su ogni cartella (separatore `;`) **prima** di caricare le DLL. Va impostata prima di importare `weasyprint` (lo fa `report_service` all'import). |
|
||||||
|
| `BROWSER_PATH` | `msedge.exe` o `chrome.exe` | Choreographer (sotto Kaleido) la legge da sé e ha la precedenza su ogni ricerca (verificato: un percorso inesistente dà `FileNotFoundError`). Senza la variabile Kaleido cerca Edge/Chrome da solo (registro e percorsi tipici). Nessun codice in `report_service.py` per questo. |
|
||||||
|
|
||||||
|
Fontconfig non richiede variabili: trova `etc\fonts` accanto alle DLL (verificato). Se manca `etc\fonts`
|
||||||
|
WeasyPrint stampa "Fontconfig error: Cannot load default config file". I font dei report vengono da
|
||||||
|
`C:\Windows\Fonts`.
|
||||||
|
|
||||||
|
### MathJax: la modifica a `report_service.py`
|
||||||
|
|
||||||
|
Kaleido 1.x inserisce nella pagina di render uno `<script>` MathJax da `cdnjs.cloudflare.com` anche quando i
|
||||||
|
grafici non hanno formule. Misurato su daitarn con Edge: con rete che scarta i pacchetti ogni grafico aspetta
|
||||||
|
~19 s (due grafici per report SPC); con rete che rifiuta subito ~2 s. `report_service.py` imposta
|
||||||
|
`pio.defaults.mathjax` su un file `.js` vuoto in `%TEMP%` (`tmf-mathjax-vuoto.js`): nessuna richiesta di rete,
|
||||||
|
report SPC in ~7 s anche a rete bloccata. Se un giorno i grafici useranno LaTeX, qui serve un MathJax locale.
|
||||||
|
`plotly.min.js` invece è già locale (dentro il pacchetto `plotly`).
|
||||||
|
|
||||||
|
## Flag Nuitka e dati da includere (per `build.ps1`, piano C)
|
||||||
|
|
||||||
|
⚠️ Stato: ricavati leggendo i sorgenti installati (versioni del `uv.lock`); **non provati con una build
|
||||||
|
Nuitka**: la verifica è la prima build di C. I dati e i metadati sono quelli che il codice legge davvero da disco.
|
||||||
|
|
||||||
|
Dati dei pacchetti (file non `.py` letti a runtime):
|
||||||
|
|
||||||
|
```text
|
||||||
|
--include-package-data=weasyprint # css/html5_ua.css, html5_ph.css, html5_ua_form.css, pdf/sRGB2014.icc
|
||||||
|
--include-package-data=pyphen # dictionaries/*.dic (84 file, sillabazione)
|
||||||
|
--include-package-data=kaleido # vendor/kaleido_scopes.js (pagina di render)
|
||||||
|
--include-package-data=plotly # package_data/plotly.min.js (Kaleido lo cerca come file accanto a plotly/__init__), templates/*.json, validators/_validators.json
|
||||||
|
--include-package-data=tzdata # zoneinfo: Windows non ha il database IANA (vedi sotto)
|
||||||
|
--include-data-dir=src/backend/templates=src/backend/templates # Jinja2: report_service legge i .html dal disco
|
||||||
|
```
|
||||||
|
|
||||||
|
`choreographer/resources/last_known_good_chrome.json` serve solo al comando che scarica Chrome: non serve.
|
||||||
|
|
||||||
|
Metadati delle distribuzioni (`importlib.metadata.version` al momento dell'import): senza, `import plotly` e
|
||||||
|
Kaleido falliscono con `PackageNotFoundError`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
--include-distribution-metadata=plotly
|
||||||
|
--include-distribution-metadata=kaleido
|
||||||
|
```
|
||||||
|
|
||||||
|
Import dinamici che l'analisi statica di Nuitka può perdere:
|
||||||
|
|
||||||
|
```text
|
||||||
|
--include-package=fontTools # ttLib/ttFont.py importa le tabelle con __import__("fontTools.ttLib.tables." + tag)
|
||||||
|
--include-package=plotly # _plotly_utils/importers.py: importlib.import_module lazy dei moduli graph_objs
|
||||||
|
--include-package=_plotly_utils
|
||||||
|
--include-module=_cffi_backend # WeasyPrint usa cffi in modalita' ABI (dlopen), serve il modulo C di cffi
|
||||||
|
--include-package=weasyprint
|
||||||
|
--include-package=kaleido
|
||||||
|
--include-package=choreographer
|
||||||
|
```
|
||||||
|
|
||||||
|
Altre note per C:
|
||||||
|
|
||||||
|
- WeasyPrint salta `os.add_dll_directory` se `sys.frozen` esiste (`weasyprint/text/ffi.py`). Nuitka di norma
|
||||||
|
non lo imposta (PyInstaller sì): se in `TieMeasureFlow.exe` le DLL non si trovassero, chiamare
|
||||||
|
`os.add_dll_directory(<P>\gtk)` da `avvio.py` prima dell'import.
|
||||||
|
- Le DLL **non** vanno compilate né incluse da Nuitka: restano file in `P\gtk\`.
|
||||||
|
- Il controllo "nessun `.py`" di `build.ps1` non deve scandire `gtk\` (non ci sono `.py`, ma è meglio
|
||||||
|
saperlo) né `licenze\`.
|
||||||
|
- **`tzdata` manca da `pyproject.toml`**: su Windows `ZoneInfo("Europe/Rome")` (usato da
|
||||||
|
`src/backend/utils/tempo.py`, quindi da ogni report) dà `ZoneInfoNotFoundError`. Va aggiunto
|
||||||
|
`tzdata; sys_platform == 'win32'` agli extra server e client (o al gruppo `windows`) e il frontend
|
||||||
|
(`src/frontend/flask_app/app.py` usa `ZoneInfo`) ne ha bisogno allo stesso modo.
|
||||||
|
- `pdfplumber` è già nell'extra `server` (il test lo usa per leggere i PDF).
|
||||||
|
|
||||||
|
## Licenze delle DLL
|
||||||
|
|
||||||
|
Le DLL sono binari MSYS2/MinGW (ucrt64) non modificati, collegati dinamicamente. `prendi-gtk.ps1` mette in
|
||||||
|
`<cartella>\licenze\` i testi di licenza di ogni pacchetto più un `<pacchetto>.txt` con nome del file e licenza.
|
||||||
|
**L'installer deve includere `gtk\licenze\`.**
|
||||||
|
|
||||||
|
| Libreria | Licenza |
|
||||||
|
|---|---|
|
||||||
|
| Pango, GLib/GObject/GIO, fribidi, libthai, libdatrie, graphite2, libintl (gettext-runtime), libiconv | LGPL 2.1 o successive |
|
||||||
|
| FreeType | FTL oppure GPL 2+ (si sceglie FTL) |
|
||||||
|
| HarfBuzz, expat, libffi, brotli, fontconfig | MIT / permissiva |
|
||||||
|
| libpng, zlib-ng, bzip2, pcre2 | permissive (libpng, Zlib, bzip2, BSD-3) |
|
||||||
|
| libgcc, libstdc++ | GPL 3 con GCC Runtime Library Exception (ridistribuzione libera) |
|
||||||
|
| libwinpthread | MIT e BSD-3 |
|
||||||
|
|
||||||
|
Per le librerie LGPL, chi riceve il pacchetto può sostituire le DLL (sono file separati in `gtk\`); i sorgenti
|
||||||
|
sono pubblici su MSYS2 (`https://packages.msys2.org/`), e l'elenco esatto delle versioni è nello script.
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
`src/backend/tests/test_report_windows.py` (saltato fuori da Windows, senza DLL, senza Edge/Chrome o senza
|
||||||
|
`tzdata`): in un sottoprocesso con WeasyPrint **vero** (il `conftest.py` altrimenti lo sostituisce con un
|
||||||
|
mock) genera su SQLite in memoria il report SPC (carta di controllo e istogramma) e quello a tabella, e
|
||||||
|
controlla con `pdfplumber` byte `%PDF-`, almeno una pagina, titoli dei grafici e segni vettoriali.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# DLL (una volta) e test
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File packaging\windows\pdf\prendi-gtk.ps1 -Destinazione packaging\windows\pdf\gtk
|
||||||
|
uv run --python 3.13 --extra server --extra dev pytest src/backend/tests/test_report_windows.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
`packaging/windows/pdf/gtk/` e `cache/` sono in `.gitignore`: le DLL non si committano.
|
||||||
|
|
||||||
|
### Prova a rete bloccata
|
||||||
|
|
||||||
|
`HTTP_PROXY`/`HTTPS_PROXY` non bastano: Edge e Chrome usano il **proxy di sistema** di Windows. Per provare davvero
|
||||||
|
il PC senza Internet: impostare temporaneamente il proxy di sistema (Impostazioni, Rete, Proxy) su un indirizzo
|
||||||
|
morto (`127.0.0.1:9` rifiuta subito; `10.255.255.1:8080` scarta i pacchetti, il caso peggiore), generare i
|
||||||
|
report e **ripristinare** il proxy. Esito del 2026-10-17 con proxy che scarta i pacchetti: report SPC e report a
|
||||||
|
tabella completi in ~7 s.
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
<#
|
||||||
|
prendi-gtk.ps1 - prepara le DLL di Pango/GTK che WeasyPrint 68.1 vuole su Windows.
|
||||||
|
|
||||||
|
COSA FA: scarica da repo.msys2.org (ucrt64) un elenco FISSO di pacchetti, ne verifica lo sha256,
|
||||||
|
estrae solo le DLL che servono a WeasyPrint (+ etc\fonts di fontconfig e le licenze) in -Destinazione.
|
||||||
|
COME SI USA: powershell -NoProfile -ExecutionPolicy Bypass -File prendi-gtk.ps1 -Destinazione <cartella>
|
||||||
|
Opzionali: -Cache <cartella> (pacchetti scaricati, default %LOCALAPPDATA%\Tielogic\tmf-gtk-cache),
|
||||||
|
-Forza (rifa' anche se la cartella e' gia' pronta).
|
||||||
|
DA COSA DIPENDE: Windows 10/11 con tar.exe (bsdtar con zstd) oppure uv + Python (modulo zstandard);
|
||||||
|
Internet solo al momento della build, mai a runtime. PowerShell 5.1 va bene, non serve admin.
|
||||||
|
IDEMPOTENTE: se la cartella ha gia' le DLL e il file .gtk-pin coincide con questi pin, esce senza scaricare.
|
||||||
|
FALLISCE se lo sha256 di un pacchetto non coincide (nessun file parziale resta in -Destinazione).
|
||||||
|
TRAPPOLA: repo.msys2.org tiene solo le versioni recenti. Se un file pinnato sparisce (404) serve aggiornare
|
||||||
|
i pin qui sotto (nome file + sha256 dal db https://repo.msys2.org/mingw/ucrt64/ucrt64.db) e riprovare i test.
|
||||||
|
La -Cache conserva i pacchetti gia' scaricati: copiarla su Wasabi/NAS evita il problema.
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$Destinazione,
|
||||||
|
[string]$Cache = (Join-Path $env:LOCALAPPDATA 'Tielogic\tmf-gtk-cache'),
|
||||||
|
[switch]$Forza
|
||||||
|
)
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
# PS 5.1 usa TLS 1.0 di default: repo.msys2.org vuole 1.2 (cosa/perche')
|
||||||
|
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||||
|
$Base = 'https://repo.msys2.org/mingw/ucrt64/'
|
||||||
|
|
||||||
|
# Pin: pacchetto, file, sha256 (dal db ucrt64 di MSYS2 del 2026-10-17), licenza dichiarata.
|
||||||
|
$Pacchetti = @(
|
||||||
|
@{ N='brotli'; F='mingw-w64-ucrt-x86_64-brotli-1.2.0-1-any.pkg.tar.zst'; H='9cc89665496ea504751476eafba15c71c554aaf3004babc2004f7c07ffd60514'; L='MIT' }
|
||||||
|
@{ N='bzip2'; F='mingw-w64-ucrt-x86_64-bzip2-1.0.8-4-any.pkg.tar.zst'; H='f03a2174034ddd2d96cecd34f617c5f8e2ef86c812b8d2bb3b8875257f2c8bfa'; L='bzip2 (BSD)' }
|
||||||
|
@{ N='expat'; F='mingw-w64-ucrt-x86_64-expat-2.9.0-1-any.pkg.tar.zst'; H='f52364dc874009316e1ee1fe46ee89784594b80d10cbcc1cc856945d575308e0'; L='MIT' }
|
||||||
|
@{ N='fontconfig'; F='mingw-w64-ucrt-x86_64-fontconfig-2.18.3-1-any.pkg.tar.zst'; H='d0b364f3ee37e3b6f6c136688ce8fa909bb6d8a69df7e08de1aa40870d9e1d60'; L='fontconfig (MIT-like)' }
|
||||||
|
@{ N='freetype'; F='mingw-w64-ucrt-x86_64-freetype-2.15.0-1-any.pkg.tar.zst'; H='a9a691f71d1a553099c54ca666e611abfb2b4ff08dae24f2a5af72e22a7e5feb'; L='GPL-2.0+ OR FTL' }
|
||||||
|
@{ N='fribidi'; F='mingw-w64-ucrt-x86_64-fribidi-1.0.17-1-any.pkg.tar.zst'; H='3581ee03cf83a9bb0e98bf688c4f532a29553500e317532d90746de295b0f8a8'; L='LGPL-2.1+' }
|
||||||
|
@{ N='gettext-runtime'; F='mingw-w64-ucrt-x86_64-gettext-runtime-1.0-1-any.pkg.tar.zst'; H='ba693dda4ac375af76ce481ff3a6e7481286546cc7dc6d56c7021dae34084157'; L='LGPL-2.1+ (libintl)' }
|
||||||
|
@{ N='glib2'; F='mingw-w64-ucrt-x86_64-glib2-2.90.1-1-any.pkg.tar.zst'; H='9a7826349279bcca0c935b01880d812be9de9609aef2205be68fd7a6128c1695'; L='LGPL-2.1+' }
|
||||||
|
@{ N='graphite2'; F='mingw-w64-ucrt-x86_64-graphite2-1.3.15-1-any.pkg.tar.zst'; H='34ca1f88bff10ef182a6b1c8e5622d5029f13b05748e361af13b552b422ea849'; L='LGPL-2.1+' }
|
||||||
|
@{ N='harfbuzz'; F='mingw-w64-ucrt-x86_64-harfbuzz-14.6.0-1-any.pkg.tar.zst'; H='8781d486c864bf91f482768cc5bd7267e49331636583d9f855af3974e6378e69'; L='MIT' }
|
||||||
|
@{ N='libdatrie'; F='mingw-w64-ucrt-x86_64-libdatrie-0.2.14-1-any.pkg.tar.zst'; H='ea92777801ad325278193a362967540752ae37035016df2f6bcee2d89928a195'; L='LGPL' }
|
||||||
|
@{ N='libffi'; F='mingw-w64-ucrt-x86_64-libffi-3.8.0-1-any.pkg.tar.zst'; H='99ad12f4ecfa00a889ef9c5c0188592368a5bac109d0c508ce4e04f29ea95a76'; L='MIT' }
|
||||||
|
@{ N='libgcc'; F='mingw-w64-ucrt-x86_64-libgcc-16.2.0-6-any.pkg.tar.zst'; H='80b2497f019977dd9528b1abc8621b1328a0115ab1f724047ccbb56bf323f11d'; L='GPL-3.0+ con GCC runtime exception' }
|
||||||
|
@{ N='libiconv'; F='mingw-w64-ucrt-x86_64-libiconv-1.19-1-any.pkg.tar.zst'; H='9a500f38c2b91808741c62fae746b3e9110b33a1ecf5c30fa0c66dbedddf7e16'; L='LGPL-2.1+' }
|
||||||
|
@{ N='libpng'; F='mingw-w64-ucrt-x86_64-libpng-1.6.59-1-any.pkg.tar.zst'; H='7f8448e06eceba8f8ce130390addea49f767bdf8f6b7e7eede8ca6d595e3d3a8'; L='libpng (permissiva)' }
|
||||||
|
@{ N='libstdc++'; F='mingw-w64-ucrt-x86_64-libstdc++-16.2.0-6-any.pkg.tar.zst'; H='33c5e58a85a538cf843a8672991cce591f0231d66da3b66a57629712d64be17f'; L='GPL-3.0+ con GCC runtime exception' }
|
||||||
|
@{ N='libthai'; F='mingw-w64-ucrt-x86_64-libthai-0.1.30-1-any.pkg.tar.zst'; H='c90ebbca3e7b4c34f4b3ffa501ba8d00cdaf6340cff7db3c46d79d5e01ef207c'; L='LGPL-2.1+' }
|
||||||
|
@{ N='libwinpthread'; F='mingw-w64-ucrt-x86_64-libwinpthread-14.0.0.r426.g4564ee4b5-2-any.pkg.tar.zst'; H='a02f5e09d666d3be657c78c86fe67c975d04fe33b5211cd5ff1b23a91c118524'; L='MIT AND BSD-3-Clause' }
|
||||||
|
@{ N='pcre2'; F='mingw-w64-ucrt-x86_64-pcre2-10.49-1-any.pkg.tar.zst'; H='63172179db724ac71e2439a7981134c1bcab8049bdddfd349b32b8f76ed52d1e'; L='BSD-3-Clause' }
|
||||||
|
@{ N='pango'; F='mingw-w64-ucrt-x86_64-pango-1.58.2-1-any.pkg.tar.zst'; H='8d86e4a018ff139b010bf357fa1c9f9030fa32ee195c54ed7e89d8ea701851c8'; L='LGPL-2.1' }
|
||||||
|
@{ N='zlib-ng-compat'; F='mingw-w64-ucrt-x86_64-zlib-ng-compat-2.3.3-2-any.pkg.tar.zst'; H='b3ca6b437fb154a0fa26652002cdda591498ee4a0e08d91cc653c3702d090f25'; L='Zlib' }
|
||||||
|
)
|
||||||
|
|
||||||
|
# DLL che WeasyPrint carica (6 radici) piu' la loro chiusura di dipendenze (calcolata con pefile il 2026-10-17).
|
||||||
|
$Dll = @(
|
||||||
|
'libbrotlicommon.dll','libbrotlidec.dll','libbz2-1.dll','libdatrie-1.dll','libexpat-1.dll','libffi-8.dll',
|
||||||
|
'libfontconfig-1.dll','libfreetype-6.dll','libfribidi-0.dll','libgcc_s_seh-1.dll','libgio-2.0-0.dll',
|
||||||
|
'libglib-2.0-0.dll','libgmodule-2.0-0.dll','libgobject-2.0-0.dll','libgraphite2.dll','libharfbuzz-0.dll',
|
||||||
|
'libharfbuzz-subset-0.dll','libiconv-2.dll','libintl-8.dll','libpango-1.0-0.dll','libpangoft2-1.0-0.dll',
|
||||||
|
'libpcre2-8-0.dll','libpng16-16.dll','libstdc++-6.dll','libthai-0.dll','libwinpthread-1.dll','zlib1.dll'
|
||||||
|
)
|
||||||
|
|
||||||
|
function Scrivi([string]$m) { Write-Host ("[prendi-gtk] " + $m) }
|
||||||
|
|
||||||
|
# Impronta dei pin: se cambia, la cartella gia' pronta non e' piu' valida.
|
||||||
|
$pinTesto = (($Pacchetti | ForEach-Object { $_.F + ':' + $_.H }) -join "`n") + "`n" + ($Dll -join ',')
|
||||||
|
$sha = [Security.Cryptography.SHA256]::Create()
|
||||||
|
$impronta = -join ($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($pinTesto)) | ForEach-Object { $_.ToString('x2') })
|
||||||
|
|
||||||
|
$marker = Join-Path $Destinazione '.gtk-pin'
|
||||||
|
$dllMancanti = @($Dll | Where-Object { -not (Test-Path (Join-Path $Destinazione $_)) })
|
||||||
|
if (-not $Forza -and (Test-Path $marker) -and ((Get-Content $marker -Raw).Trim() -eq $impronta) -and $dllMancanti.Count -eq 0) {
|
||||||
|
Scrivi "gia' pronto in $Destinazione (pin $($impronta.Substring(0,12)))"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- estrazione .zst: tar.exe di Windows se sa di zstd, altrimenti Python zstandard via uv ---
|
||||||
|
$tar = Join-Path $env:SystemRoot 'System32\tar.exe'
|
||||||
|
$tarZstd = $false
|
||||||
|
if (Test-Path $tar) { $tarZstd = ((& $tar --version) -join ' ') -match 'zstd' }
|
||||||
|
function Estrai([string]$pkg, [string]$dest) {
|
||||||
|
if ($tarZstd) {
|
||||||
|
& $tar -xf $pkg -C $dest
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "tar.exe ha fallito su $pkg" }
|
||||||
|
} else {
|
||||||
|
$py = "import sys,tarfile,zstandard`nd=zstandard.ZstdDecompressor()`nwith open(sys.argv[1],'rb') as f, d.stream_reader(f) as r:`n tarfile.open(fileobj=r,mode='r|').extractall(sys.argv[2])"
|
||||||
|
$tmpPy = Join-Path ([IO.Path]::GetTempPath()) ('estrai-zst-' + [guid]::NewGuid().ToString('N') + '.py')
|
||||||
|
Set-Content -Path $tmpPy -Value $py -Encoding ASCII
|
||||||
|
try {
|
||||||
|
& uv run --quiet --with zstandard python -I $tmpPy $pkg $dest
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "estrazione zstd via uv fallita su $pkg" }
|
||||||
|
}
|
||||||
|
finally { Remove-Item $tmpPy -ErrorAction SilentlyContinue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path $Cache | Out-Null
|
||||||
|
# Lavoro in una cartella temporanea, poi sposto: un errore a meta' non lascia mezza cartella in -Destinazione.
|
||||||
|
$tmp = Join-Path ([IO.Path]::GetTempPath()) ('tmf-gtk-' + [guid]::NewGuid().ToString('N'))
|
||||||
|
$stage = Join-Path $tmp 'stage'
|
||||||
|
New-Item -ItemType Directory -Force -Path $stage | Out-Null
|
||||||
|
try {
|
||||||
|
foreach ($p in $Pacchetti) {
|
||||||
|
$file = Join-Path $Cache $p.F
|
||||||
|
$ok = $false
|
||||||
|
if (Test-Path $file) {
|
||||||
|
$ok = ((Get-FileHash $file -Algorithm SHA256).Hash.ToLower() -eq $p.H)
|
||||||
|
if (-not $ok) { Scrivi "cache corrotta, riscarico $($p.F)"; Remove-Item $file -Force }
|
||||||
|
}
|
||||||
|
if (-not $ok) {
|
||||||
|
Scrivi "scarico $($p.F)"
|
||||||
|
$parz = $file + '.parz'
|
||||||
|
# il '+' di libstdc++ va codificato nell'URL
|
||||||
|
Invoke-WebRequest -UseBasicParsing -Uri ($Base + [Uri]::EscapeDataString($p.F)) -OutFile $parz
|
||||||
|
$h = (Get-FileHash $parz -Algorithm SHA256).Hash.ToLower()
|
||||||
|
if ($h -ne $p.H) { Remove-Item $parz -Force; throw "sha256 diverso per $($p.F): atteso $($p.H), trovato $h" }
|
||||||
|
Move-Item $parz $file -Force
|
||||||
|
}
|
||||||
|
$ex = Join-Path $tmp $p.N
|
||||||
|
New-Item -ItemType Directory -Force -Path $ex | Out-Null
|
||||||
|
Estrai $file $ex
|
||||||
|
$bin = Join-Path $ex 'ucrt64\bin'
|
||||||
|
if (Test-Path $bin) { Get-ChildItem $bin -Filter *.dll | Copy-Item -Destination $stage -Force }
|
||||||
|
# fontconfig: senza etc\fonts WeasyPrint non trova i font (errore "Cannot load default config file")
|
||||||
|
if ($p.N -eq 'fontconfig') { Copy-Item (Join-Path $ex 'ucrt64\etc\fonts') (Join-Path $stage 'etc\fonts') -Recurse -Force }
|
||||||
|
# licenze: le DLL LGPL si ridistribuiscono con il testo della licenza
|
||||||
|
New-Item -ItemType Directory -Force -Path (Join-Path $stage 'licenze') | Out-Null
|
||||||
|
$lic = Join-Path $ex 'ucrt64\share\licenses'
|
||||||
|
if (Test-Path $lic) { Copy-Item $lic (Join-Path $stage ('licenze\' + $p.N)) -Recurse -Force }
|
||||||
|
Set-Content -Path (Join-Path $stage ('licenze\' + $p.N + '.txt')) -Value ($p.F + "`nlicenza: " + $p.L + "`nsorgente: https://packages.msys2.org/") -Encoding UTF8
|
||||||
|
}
|
||||||
|
# Tengo solo le DLL in whitelist: le altre DLL dei pacchetti non servono.
|
||||||
|
Get-ChildItem $stage -Filter *.dll | Where-Object { $Dll -notcontains $_.Name } | Remove-Item -Force
|
||||||
|
$mancanti = @($Dll | Where-Object { -not (Test-Path (Join-Path $stage $_)) })
|
||||||
|
if ($mancanti.Count -gt 0) { throw "DLL attese non trovate: $($mancanti -join ', ')" }
|
||||||
|
Set-Content -Path (Join-Path $stage '.gtk-pin') -Value $impronta -Encoding ASCII
|
||||||
|
|
||||||
|
if (Test-Path $Destinazione) { Get-ChildItem $Destinazione -Force | Remove-Item -Recurse -Force }
|
||||||
|
New-Item -ItemType Directory -Force -Path $Destinazione | Out-Null
|
||||||
|
Get-ChildItem $stage -Force | Move-Item -Destination $Destinazione -Force
|
||||||
|
Scrivi "pronto: $($Dll.Count) DLL in $Destinazione"
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Remove-Item $tmp -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<#
|
||||||
|
Cosa fa: avvia TieMeasureFlow DA SORGENTE su una macchina di sviluppo Windows, senza la build
|
||||||
|
Nuitka (che richiede ~20 minuti): stessi quattro server e stesse porte dell'exe (8780 web,
|
||||||
|
8000 backend, 8100 visione, 8765 camera), stessa cartella dati %PROGRAMDATA%\Tielogic\TieMeasureFlow.
|
||||||
|
Prima dell'avvio: ambiente Python 3.13 in build\venv313 (uv sync, solo con -Sincronizza o se manca),
|
||||||
|
tailwind.css e traduzioni .mo ricompilati, DLL di WeasyPrint in build\gtk (scaricate con
|
||||||
|
pdf\prendi-gtk.ps1 se mancano).
|
||||||
|
|
||||||
|
Come si usa (dalla radice del repo):
|
||||||
|
powershell -ExecutionPolicy Bypass -File packaging\windows\sviluppo.ps1 [-Sincronizza] [-Ascolto 0.0.0.0] [-EscludiCamere OECS]
|
||||||
|
Dopo una modifica: Ctrl+C e rilancio (pochi secondi). Template e JS non richiedono altro;
|
||||||
|
classi Tailwind nuove e msgid nuovi vengono ricompilati a ogni avvio.
|
||||||
|
|
||||||
|
Dipende da: uv, npx (Node), la licenza tiemeasureflow di QUESTA macchina in
|
||||||
|
%PROGRAMDATA%\Tielogic\licenze\tiemeasureflow.json (impronta: avvio.py --impronta).
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[switch]$Sincronizza, # forza uv sync (dipendenze cambiate)
|
||||||
|
[string]$Ascolto = '127.0.0.1', # indirizzo di ascolto della UI web ($Host e' riservato in PowerShell)
|
||||||
|
[string]$EscludiCamere = '' # backend nativi da saltare (TieVision-Core#339): OECS se c'e' il runtime OECS ma in fiera si usa GenTL
|
||||||
|
)
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$radice = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||||
|
$env:UV_PROJECT_ENVIRONMENT = Join-Path $radice 'build\venv313' # stesso ambiente della build
|
||||||
|
$py = Join-Path $env:UV_PROJECT_ENVIRONMENT 'Scripts\python.exe'
|
||||||
|
|
||||||
|
if ($Sincronizza -or -not (Test-Path $py)) {
|
||||||
|
uv sync --python 3.13 --frozen --extra server --extra client --extra vision-worker --group windows
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "uv sync fallito" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# CSS e traduzioni: non sono nel repo, come in build.ps1
|
||||||
|
$flask = Join-Path $radice 'src\frontend\flask_app'
|
||||||
|
Push-Location $flask
|
||||||
|
try {
|
||||||
|
npx --yes tailwindcss@3.4.19 -i static/css/input.css -o static/css/tailwind.css --minify
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Tailwind fallito" }
|
||||||
|
& (Join-Path $env:UV_PROJECT_ENVIRONMENT 'Scripts\pybabel.exe') -q compile -d translations
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "pybabel compile fallito" }
|
||||||
|
} finally { Pop-Location }
|
||||||
|
|
||||||
|
# DLL di WeasyPrint fuori dalla radice (gtk\ nella radice non e' ignorata da git): build\gtk
|
||||||
|
$gtk = Join-Path $radice 'build\gtk'
|
||||||
|
if (-not (Test-Path (Join-Path $gtk '*.dll'))) {
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'pdf\prendi-gtk.ps1') -Destinazione $gtk
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "prendi-gtk.ps1 fallito" }
|
||||||
|
}
|
||||||
|
$env:WEASYPRINT_DLL_DIRECTORIES = $gtk
|
||||||
|
|
||||||
|
if ($EscludiCamere) { $env:VS_CAMERA_ESCLUDI = $EscludiCamere } # #339: stesso percorso camera del PC fiera
|
||||||
|
Set-Location $radice
|
||||||
|
& $py (Join-Path $PSScriptRoot 'avvio.py') --senza-browser --senza-finestre --host $Ascolto # errori in console, niente MessageBox
|
||||||
|
exit $LASTEXITCODE
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
"""Test di packaging/windows/avvio.py (TieMeasureFlow#76): licenza firmata con la chiave di prova
|
||||||
|
del contratto v1, nessun server vero (avvia_server e migrazioni sono sostituiti).
|
||||||
|
|
||||||
|
Si lanciano con l'ambiente 3.13 del pacchetto (serve visionsuite.license):
|
||||||
|
UV_PROJECT_ENVIRONMENT=build/venv313 uv run --python 3.13 --frozen --extra server --extra client \
|
||||||
|
--extra vision-worker --group windows --with pytest pytest packaging/windows/tests -q
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import functools
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
|
|
||||||
|
RADICE = Path(__file__).resolve().parents[3]
|
||||||
|
# vettore di prova del contratto licenza v1 (chiave finta, solo test)
|
||||||
|
SEED_TEST = base64.b64decode("AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=")
|
||||||
|
PUBBLICA_TEST = base64.b64decode("A6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=")
|
||||||
|
IMPRONTA = "ab" * 32
|
||||||
|
|
||||||
|
|
||||||
|
def _firma(dati: dict) -> str:
|
||||||
|
# stessa canonicalizzazione del contratto (byte firmati)
|
||||||
|
byte = json.dumps(dati, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||||
|
return base64.b64encode(Ed25519PrivateKey.from_private_bytes(SEED_TEST).sign(byte)).decode()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def avvio(monkeypatch, tmp_path):
|
||||||
|
# avvio.py non e' in un pacchetto: lo si carica dal percorso
|
||||||
|
spec = importlib.util.spec_from_file_location("avvio_tmf", RADICE / "packaging/windows/avvio.py")
|
||||||
|
m = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(m)
|
||||||
|
monkeypatch.setenv("PROGRAMDATA", str(tmp_path)) # ProgramData finta
|
||||||
|
monkeypatch.delenv("TIELOGIC_LICENZA", raising=False)
|
||||||
|
monkeypatch.setattr(os, "environ", os.environ.copy()) # le variabili impostate da avvio non escono dal test
|
||||||
|
monkeypatch.setattr(m.lic, "impronta", lambda: IMPRONTA)
|
||||||
|
# verifica reale (formato, firma, prodotto, macchina, scadenza) ma con la chiave di prova
|
||||||
|
verifica_reale = m.lic.verifica
|
||||||
|
monkeypatch.setattr(m.lic, "verifica", functools.partial(
|
||||||
|
verifica_reale, chiave_pubblica=PUBBLICA_TEST, impronta_attesa=IMPRONTA))
|
||||||
|
m.chiamate = []
|
||||||
|
monkeypatch.setattr(m, "avvia_server", lambda host, dati: m.chiamate.append((host, dati)) or [])
|
||||||
|
monkeypatch.setattr(m, "esegui_migrazioni", lambda: m.chiamate.append("migrazioni"))
|
||||||
|
m.aperti = []
|
||||||
|
monkeypatch.setattr(m.webbrowser, "open", lambda url: m.aperti.append(url))
|
||||||
|
monkeypatch.setattr(m, "_prepara_log", lambda dati=None: None)
|
||||||
|
monkeypatch.setattr(m, "_porta_aperta", lambda porta: False)
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def _scrivi_licenza(tmp_path: Path, **modifiche) -> Path:
|
||||||
|
dati = {"versione": 1, "prodotto": "tiemeasureflow", "cliente": "Prova Srl", "impronta": IMPRONTA,
|
||||||
|
"emessa": date.today().isoformat(), "scadenza": (date.today() + timedelta(days=30)).isoformat()}
|
||||||
|
dati.update(modifiche)
|
||||||
|
p = tmp_path / "Tielogic" / "licenze" / "tiemeasureflow.json"
|
||||||
|
p.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
p.write_text(json.dumps({"dati": dati, "firma": _firma(dati)}), encoding="utf-8")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def test_licenza_mancante_non_avvia_e_scrive_impronta(avvio, capsys):
|
||||||
|
assert avvio.main(["--senza-finestre"]) == 1
|
||||||
|
assert avvio.chiamate == [] and avvio.aperti == []
|
||||||
|
assert avvio.percorso_impronta().read_text(encoding="utf-8").strip() == IMPRONTA
|
||||||
|
err = capsys.readouterr().err
|
||||||
|
assert "Licenza non trovata" in err and IMPRONTA in err and "tiemeasureflow.json" in err
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("modifica,testo", [
|
||||||
|
({"scadenza": "2020-01-01"}, "scaduta"),
|
||||||
|
({"impronta": "cd" * 32}, "altra macchina"),
|
||||||
|
({"prodotto": "tievision-studio"}, "altro prodotto"),
|
||||||
|
])
|
||||||
|
def test_licenza_non_valida_non_avvia(avvio, tmp_path, capsys, modifica, testo):
|
||||||
|
_scrivi_licenza(tmp_path, **modifica)
|
||||||
|
assert avvio.main(["--senza-finestre"]) == 1
|
||||||
|
assert avvio.chiamate == []
|
||||||
|
assert testo in capsys.readouterr().err
|
||||||
|
assert avvio.percorso_impronta().read_text(encoding="utf-8").strip() == IMPRONTA
|
||||||
|
|
||||||
|
|
||||||
|
def test_licenza_manomessa_non_avvia(avvio, tmp_path, capsys):
|
||||||
|
p = _scrivi_licenza(tmp_path)
|
||||||
|
doc = json.loads(p.read_text(encoding="utf-8"))
|
||||||
|
doc["dati"]["cliente"] = "Altro Cliente" # dati cambiati dopo la firma
|
||||||
|
p.write_text(json.dumps(doc), encoding="utf-8")
|
||||||
|
assert avvio.main(["--senza-finestre"]) == 1
|
||||||
|
assert "Firma" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_licenza_valida_prepara_dati_e_avvia(avvio, tmp_path):
|
||||||
|
_scrivi_licenza(tmp_path)
|
||||||
|
assert avvio.main(["--senza-finestre", "--senza-browser", "--host", "0.0.0.0"]) == 0
|
||||||
|
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
|
||||||
|
# prima le migrazioni, poi i server
|
||||||
|
assert avvio.chiamate == ["migrazioni", ("0.0.0.0", dati)]
|
||||||
|
assert (dati / "config.env").is_file() and (dati / "uploads").is_dir() and (dati / "camera").is_dir()
|
||||||
|
assert not avvio.percorso_impronta().exists() # con licenza valida non si scrive nulla
|
||||||
|
assert avvio.aperti == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_variabili_del_contratto(avvio, tmp_path):
|
||||||
|
_scrivi_licenza(tmp_path)
|
||||||
|
avvio.main(["--senza-finestre", "--senza-browser"])
|
||||||
|
e = os.environ
|
||||||
|
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
|
||||||
|
assert e["APP_ENV"] == "production"
|
||||||
|
assert e["DB_URL"] == f"sqlite+aiosqlite:///{(dati / 'tmf.db').as_posix()}" and "\\" not in e["DB_URL"]
|
||||||
|
assert e["UPLOAD_DIR"] == str(dati / "uploads") == e["VISION_RECIPES_ROOT"]
|
||||||
|
assert e["VISION_WORKER_URL"] == "http://127.0.0.1:8100"
|
||||||
|
assert e["API_SERVER_URL"] == "http://127.0.0.1:8000"
|
||||||
|
assert e["SESSION_COOKIE_SECURE"] == "0"
|
||||||
|
for k in ("SERVER_SECRET_KEY", "CLIENT_SECRET_KEY", "SETUP_PASSWORD", "DB_PASSWORD"):
|
||||||
|
assert len(e[k]) >= 32
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_env_generato_una_volta_e_riletto(avvio, tmp_path):
|
||||||
|
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
|
||||||
|
primo = avvio.carica_segreti(dati)
|
||||||
|
testo = (dati / "config.env").read_text(encoding="utf-8")
|
||||||
|
assert set(primo) == {"SERVER_SECRET_KEY", "CLIENT_SECRET_KEY", "SETUP_PASSWORD", "DB_PASSWORD", "STATION_CODE"}
|
||||||
|
assert len({primo[k] for k in avvio.SEGRETI}) == 4 # segreti distinti
|
||||||
|
assert primo["STATION_CODE"] == "ST-DEFAULT"
|
||||||
|
secondo = avvio.carica_segreti(dati)
|
||||||
|
assert secondo == primo and (dati / "config.env").read_text(encoding="utf-8") == testo
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_env_completa_i_segreti_mancanti(avvio, tmp_path):
|
||||||
|
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
|
||||||
|
dati.mkdir(parents=True)
|
||||||
|
(dati / "config.env").write_text("# nota\nSETUP_PASSWORD=scelta-da-me\n", encoding="utf-8")
|
||||||
|
v = avvio.carica_segreti(dati)
|
||||||
|
assert v["SETUP_PASSWORD"] == "scelta-da-me" and len(v["SERVER_SECRET_KEY"]) >= 32
|
||||||
|
assert avvio.carica_segreti(dati) == v
|
||||||
|
|
||||||
|
|
||||||
|
def test_seconda_istanza_apre_solo_il_browser(avvio, tmp_path, monkeypatch):
|
||||||
|
_scrivi_licenza(tmp_path)
|
||||||
|
monkeypatch.setattr(avvio, "_porta_aperta", lambda porta: True)
|
||||||
|
monkeypatch.setattr(avvio, "_e_tmf", lambda: True)
|
||||||
|
assert avvio.main(["--senza-finestre"]) == 0
|
||||||
|
assert avvio.aperti == ["http://127.0.0.1:8780/"] and avvio.chiamate == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_porta_occupata_da_altro_programma(avvio, tmp_path, monkeypatch, capsys):
|
||||||
|
_scrivi_licenza(tmp_path)
|
||||||
|
monkeypatch.setattr(avvio, "_porta_aperta", lambda porta: True)
|
||||||
|
monkeypatch.setattr(avvio, "_e_tmf", lambda: False)
|
||||||
|
assert avvio.main(["--senza-finestre"]) == 2
|
||||||
|
assert "8780" in capsys.readouterr().err and avvio.chiamate == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_migrazione_fallita_esce_con_3(avvio, tmp_path, monkeypatch, capsys):
|
||||||
|
_scrivi_licenza(tmp_path)
|
||||||
|
|
||||||
|
def rotta():
|
||||||
|
raise RuntimeError("db bloccato")
|
||||||
|
|
||||||
|
monkeypatch.setattr(avvio, "esegui_migrazioni", rotta)
|
||||||
|
assert avvio.main(["--senza-finestre"]) == 3
|
||||||
|
assert "db bloccato" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_impronta_si_stampa_senza_licenza(avvio, capsys):
|
||||||
|
assert avvio.main(["--impronta", "--senza-finestre"]) == 0
|
||||||
|
assert IMPRONTA in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_nessun_utente_rileva_il_primo_avvio(avvio, tmp_path):
|
||||||
|
db = tmp_path / "t.db"
|
||||||
|
assert avvio._nessun_utente(db) is False # file assente: nessuna conclusione
|
||||||
|
con = sqlite3.connect(db)
|
||||||
|
con.execute("CREATE TABLE users (id INTEGER)")
|
||||||
|
con.commit()
|
||||||
|
assert avvio._nessun_utente(db) is True
|
||||||
|
con.execute("INSERT INTO users VALUES (1)")
|
||||||
|
con.commit()
|
||||||
|
con.close()
|
||||||
|
assert avvio._nessun_utente(db) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_chiavi_extra_di_config_env_passano_ma_non_sovrascrivono_il_contratto(avvio, tmp_path):
|
||||||
|
_scrivi_licenza(tmp_path)
|
||||||
|
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
|
||||||
|
dati.mkdir(parents=True)
|
||||||
|
(dati / "config.env").write_text("STATION_CODE=ST-FIERA\nSTATION_SWITCH_ENABLED=1\nAPP_ENV=dev\n", encoding="utf-8")
|
||||||
|
avvio.main(["--senza-finestre", "--senza-browser"])
|
||||||
|
assert os.environ["STATION_CODE"] == "ST-FIERA" and os.environ["STATION_SWITCH_ENABLED"] == "1"
|
||||||
|
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)]
|
||||||
@@ -81,6 +81,20 @@ vision-worker = [
|
|||||||
"python-multipart>=0.0.6; python_version>='3.13'",
|
"python-multipart>=0.0.6; python_version>='3.13'",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Pacchetto Windows (packaging/windows, TieMeasureFlow#76): solo per build.ps1, non per
|
||||||
|
# i container. Si usa con --python 3.13 insieme agli extra server, client e vision-worker.
|
||||||
|
[dependency-groups]
|
||||||
|
windows = [
|
||||||
|
"tzdata; sys_platform == 'win32'", # ZoneInfo("Europe/Rome") (backend e frontend): Windows non ha il database IANA
|
||||||
|
"aiosqlite>=0.20.0", # database SQLite su file (DB_URL, piano A)
|
||||||
|
"waitress>=3.0.0", # server WSGI del frontend Flask (gunicorn non gira su Windows)
|
||||||
|
"vs-camera[genicam]; python_version>='3.13'", # scripts/camera_service.py: camere GigE/GenTL (producer .cti del vendor)
|
||||||
|
"vs-license; python_version>='3.13'", # licenza Tielogic v1 (impronta macchina, firma Ed25519)
|
||||||
|
"nuitka>=2.5", # compilazione standalone
|
||||||
|
"ordered-set>=4.1", # richiesto da Nuitka per la modalita' standalone
|
||||||
|
"zstandard>=0.22", # compressione dei dati incorporati di Nuitka
|
||||||
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
# Backend
|
# Backend
|
||||||
server = "uvicorn:run" # placeholder, real CMD lives in Dockerfile
|
server = "uvicorn:run" # placeholder, real CMD lives in Dockerfile
|
||||||
@@ -105,6 +119,8 @@ vs-measure = { path = "vendor/visionsuite/packages/vs-measure", editable = true
|
|||||||
vs-pm2d = { path = "vendor/visionsuite/packages/vs-pm2d", editable = true }
|
vs-pm2d = { path = "vendor/visionsuite/packages/vs-pm2d", editable = true }
|
||||||
vs-tools = { path = "vendor/visionsuite/packages/vs-tools", editable = true } # #46
|
vs-tools = { path = "vendor/visionsuite/packages/vs-tools", editable = true } # #46
|
||||||
vs-dxf = { path = "vendor/visionsuite/packages/vs-dxf", editable = true } # #46
|
vs-dxf = { path = "vendor/visionsuite/packages/vs-dxf", editable = true } # #46
|
||||||
|
vs-camera = { path = "vendor/visionsuite/packages/vs-camera", editable = true } # #76 camera di stazione
|
||||||
|
vs-license = { path = "vendor/visionsuite/packages/vs-license", editable = true } # #76 licenza del pacchetto Windows
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
|
|||||||
@@ -0,0 +1,781 @@
|
|||||||
|
"""Calibrazione a scacchiera dell'acquisitore (TieMeasureFlow, #83).
|
||||||
|
|
||||||
|
Cosa fa: da una foto di scacchiera piana calcola la scala (px/mm) con
|
||||||
|
TieVision-Core (visionsuite.calibration), e conserva un risultato per sorgente
|
||||||
|
camera in un JSON (scrittura atomica).
|
||||||
|
#91: le calibrazioni stanno in un archivio UNICO, salvate con nome (classe Archivio);
|
||||||
|
una sorgente ne ha una attiva (attive.json) e il formato vecchio (una per sorgente)
|
||||||
|
si migra da solo.
|
||||||
|
#84: `calibra_scacchiera_densa(pixels, square_mm)` calibra da UNA foto di
|
||||||
|
scacchiera che riempie tutto il campo (ottica telecentrica): trova da sola la
|
||||||
|
griglia, costruisce la mappa di distorsione (DistortionMap) e il mm/px
|
||||||
|
dell'immagine raddrizzata. L'Archivio salva la mappa accanto al JSON (.npz) e
|
||||||
|
`raddrizza()` la applica ai frame.
|
||||||
|
Uso: importato da scripts/camera_service.py (rotte /camera/calibrate e
|
||||||
|
/camera/calibration); `calibra_scacchiera(gray, cols, rows, square_mm)` e
|
||||||
|
`calibra_scacchiera_densa(gray, square_mm)` sono logica pura, senza FastAPI,
|
||||||
|
e si provano da sole.
|
||||||
|
Dipende da: numpy, opencv, visionsuite (vs-core). Niente altro (no scipy).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import unicodedata # #91 caratteri di controllo nei nomi
|
||||||
|
from collections import deque
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from visionsuite.calibration import calibrate_planar, find_chessboard
|
||||||
|
from visionsuite.calibration.distortion_map import DistortionMap # #84 mappa densa
|
||||||
|
from visionsuite.core.image import Image
|
||||||
|
|
||||||
|
|
||||||
|
class CalibrazioneError(ValueError):
|
||||||
|
"""Parametri invalidi o scacchiera non trovata: input sbagliato, non un guasto (422)."""
|
||||||
|
|
||||||
|
|
||||||
|
def _gray_u8(pixels: np.ndarray) -> np.ndarray:
|
||||||
|
"""Immagine -> grigio uint8 2D."""
|
||||||
|
px = np.asarray(pixels)
|
||||||
|
if px.ndim == 3:
|
||||||
|
px = cv2.cvtColor(px, cv2.COLOR_BGRA2GRAY if px.shape[2] == 4 else cv2.COLOR_BGR2GRAY)
|
||||||
|
if px.dtype != np.uint8:
|
||||||
|
# float/uint16 -> scala sul massimo, come fa vs-core
|
||||||
|
m = float(px.max()) if px.size else 1.0
|
||||||
|
px = np.clip(px.astype(np.float32) / max(m, 1e-9) * 255, 0, 255).astype(np.uint8)
|
||||||
|
return np.ascontiguousarray(px)
|
||||||
|
|
||||||
|
|
||||||
|
def calibra_scacchiera(pixels, cols: int, rows: int, square_mm: float) -> dict:
|
||||||
|
"""Scala (px/mm) da una foto di scacchiera piana. cols/rows = angoli INTERNI.
|
||||||
|
|
||||||
|
Ritorna {px_per_mm, mm_per_px, rms_px, n_corners, cols, rows, square_mm,
|
||||||
|
image_width, image_height, scale_spread_pct, metodo, distorsione,
|
||||||
|
overlay_png (b64)}.
|
||||||
|
"""
|
||||||
|
if cols < 2 or rows < 2:
|
||||||
|
raise CalibrazioneError("cols e rows (angoli interni) devono essere almeno 2")
|
||||||
|
if not (square_mm > 0 and math.isfinite(square_mm)):
|
||||||
|
raise CalibrazioneError("square_mm deve essere > 0")
|
||||||
|
|
||||||
|
frame = _gray_u8(pixels)
|
||||||
|
image = Image(frame)
|
||||||
|
try:
|
||||||
|
image_pts, world_pts = find_chessboard(image, (cols, rows), square_mm)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
# find_chessboard solleva RuntimeError quando la scacchiera non c'e'
|
||||||
|
raise CalibrazioneError(
|
||||||
|
f"scacchiera {cols}x{rows} non trovata nell'immagine: {exc}") from exc
|
||||||
|
model = calibrate_planar(image, image_pts, world_pts) # omografia pura
|
||||||
|
|
||||||
|
# Scala al centro della scacchiera (puo' non stare al centro immagine)
|
||||||
|
x0, y0 = image_pts.min(axis=0)
|
||||||
|
x1, y1 = image_pts.max(axis=0)
|
||||||
|
mm_per_px = model.pixel_size((x0 + x1) / 2.0, (y0 + y1) / 2.0)
|
||||||
|
if not (mm_per_px > 0 and math.isfinite(mm_per_px)):
|
||||||
|
raise CalibrazioneError("calibrazione degenere: scala non valida")
|
||||||
|
# Scarto massimo % della scala ai quattro angoli dell'area della scacchiera
|
||||||
|
# rispetto al centro: ottica telecentrica/piana ~ 0, prospettiva = grande.
|
||||||
|
spread = max(abs(model.pixel_size(x, y) - mm_per_px) / mm_per_px * 100.0
|
||||||
|
for x in (x0, x1) for y in (y0, y1))
|
||||||
|
|
||||||
|
# Overlay: angoli disegnati sull'immagine (PNG base64)
|
||||||
|
bgr = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR)
|
||||||
|
cv2.drawChessboardCorners(
|
||||||
|
bgr, (cols, rows), image_pts.reshape(-1, 1, 2).astype(np.float32), True)
|
||||||
|
ok, png = cv2.imencode(".png", bgr)
|
||||||
|
if not ok:
|
||||||
|
raise RuntimeError("PNG dell'overlay non codificabile")
|
||||||
|
|
||||||
|
h, w = frame.shape
|
||||||
|
return {
|
||||||
|
"px_per_mm": 1.0 / mm_per_px,
|
||||||
|
"mm_per_px": mm_per_px,
|
||||||
|
"rms_px": float(model.rms_reprojection),
|
||||||
|
"n_corners": int(len(image_pts)),
|
||||||
|
"cols": int(cols),
|
||||||
|
"rows": int(rows),
|
||||||
|
"square_mm": float(square_mm),
|
||||||
|
"image_width": int(w),
|
||||||
|
"image_height": int(h),
|
||||||
|
"scale_spread_pct": float(spread),
|
||||||
|
"metodo": "planare", # #84: il metodo con cols/rows resta quello di prima
|
||||||
|
"distorsione": False,
|
||||||
|
"overlay_png": base64.b64encode(png.tobytes()).decode("ascii"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---- #84: calibrazione densa (scacchiera che riempie il campo, griglia non nota)
|
||||||
|
|
||||||
|
# #84: findChessboardCornersSBWithMeta (verificato su opencv 5.0.0) su una scacchiera
|
||||||
|
# grande quanto l'immagine trova solo frammenti (2064x1544 con quadrati da 40 px:
|
||||||
|
# 5x5, 28x6...). Per questo la griglia si cerca qui: sellini della Hessiana a piu'
|
||||||
|
# scale + crescita del reticolo; poi cornerSubPix.
|
||||||
|
_SIGME = (1.5, 2.2, 3.2, 4.5, 6.5, 9.0, 13.0) # scale dei sellini (passo ~ 5..120 px)
|
||||||
|
_MAX_CANDIDATI = 6000
|
||||||
|
|
||||||
|
|
||||||
|
def _candidati(gray: np.ndarray, sigma: float) -> np.ndarray:
|
||||||
|
"""Massimi locali del 'selliness' (-det Hessiana) = angoli di scacchiera, (n,2) x,y."""
|
||||||
|
b = cv2.GaussianBlur(gray.astype(np.float32), (0, 0), sigma)
|
||||||
|
ixx = cv2.Sobel(b, cv2.CV_32F, 2, 0, ksize=3)
|
||||||
|
iyy = cv2.Sobel(b, cv2.CV_32F, 0, 2, ksize=3)
|
||||||
|
ixy = cv2.Sobel(b, cv2.CV_32F, 1, 1, ksize=3)
|
||||||
|
r = ixy * ixy - ixx * iyy # >0 solo dove l'immagine ha un sellino
|
||||||
|
massimo = float(r.max())
|
||||||
|
if massimo <= 0:
|
||||||
|
return np.zeros((0, 2))
|
||||||
|
k = 2 * int(math.ceil(1.5 * sigma)) + 1
|
||||||
|
picchi = (r >= cv2.dilate(r, np.ones((k, k), np.uint8))) & (r > 0.1 * massimo)
|
||||||
|
ys, xs = np.nonzero(picchi)
|
||||||
|
if len(xs) > _MAX_CANDIDATI: # tiene i piu' forti
|
||||||
|
forti = np.argsort(r[ys, xs])[::-1][:_MAX_CANDIDATI]
|
||||||
|
ys, xs = ys[forti], xs[forti]
|
||||||
|
return np.stack([xs, ys], axis=1).astype(np.float64)
|
||||||
|
|
||||||
|
|
||||||
|
def _e_angolo_x(lisc: np.ndarray, delta: float, p: np.ndarray, u: np.ndarray, v: np.ndarray) -> bool:
|
||||||
|
"""True se attorno a p i quattro quadranti alternano chiaro/scuro (vero angolo X).
|
||||||
|
|
||||||
|
Scarta le giunzioni a L/T al bordo della scacchiera (3 quadranti uguali), che
|
||||||
|
il selliness da solo accetta e che spostano la griglia.
|
||||||
|
"""
|
||||||
|
h, w = lisc.shape
|
||||||
|
campioni = []
|
||||||
|
for q in (0.25 * (u + v), -0.25 * (u + v), 0.25 * (u - v), -0.25 * (u - v)):
|
||||||
|
x, y = int(round(p[0] + q[0])), int(round(p[1] + q[1]))
|
||||||
|
if not (0 <= x < w and 0 <= y < h):
|
||||||
|
return False
|
||||||
|
campioni.append(float(lisc[y, x]))
|
||||||
|
s1, s2, t1, t2 = campioni
|
||||||
|
return (min(s1, s2) > max(t1, t2) + delta) or (max(s1, s2) < min(t1, t2) - delta)
|
||||||
|
|
||||||
|
|
||||||
|
def _cresci_reticolo(pts: np.ndarray, centro: tuple[float, float],
|
||||||
|
lisc: np.ndarray, delta: float) -> dict | None:
|
||||||
|
"""Assegna un indice (riga, colonna) ai candidati che stanno su un reticolo.
|
||||||
|
|
||||||
|
Parte dal candidato piu' vicino al centro, ricava i due vettori di passo dai
|
||||||
|
vicini e cresce a macchia d'olio: la base si aggiorna a ogni passo, quindi
|
||||||
|
segue una distorsione lenta. Ritorna {(i, j): indice candidato} o None.
|
||||||
|
"""
|
||||||
|
if len(pts) < 9:
|
||||||
|
return None
|
||||||
|
seme = int(np.argmin(np.hypot(pts[:, 0] - centro[0], pts[:, 1] - centro[1])))
|
||||||
|
d = np.hypot(pts[:, 0] - pts[seme, 0], pts[:, 1] - pts[seme, 1])
|
||||||
|
vicini = np.argsort(d)[1:9]
|
||||||
|
u = pts[vicini[0]] - pts[seme]
|
||||||
|
lu = float(np.hypot(*u))
|
||||||
|
v = None
|
||||||
|
for k in vicini[1:]:
|
||||||
|
w = pts[k] - pts[seme]
|
||||||
|
lw = float(np.hypot(*w))
|
||||||
|
# il secondo vettore: quasi ortogonale al primo e di lunghezza simile
|
||||||
|
if lu > 0 and lw > 0 and abs(float(u @ w)) / (lu * lw) < 0.4 and lw < 1.5 * lu:
|
||||||
|
v = w
|
||||||
|
break
|
||||||
|
if v is None or lu < 3:
|
||||||
|
return None
|
||||||
|
posto = {(0, 0): seme}
|
||||||
|
indice = {seme: (0, 0)}
|
||||||
|
basi = {seme: (u, v)}
|
||||||
|
coda = deque([seme])
|
||||||
|
while coda:
|
||||||
|
a = coda.popleft()
|
||||||
|
i, j = indice[a]
|
||||||
|
u, v = basi[a]
|
||||||
|
tol = 0.3 * min(float(np.hypot(*u)), float(np.hypot(*v)))
|
||||||
|
for di, dj, vec in ((0, 1, u), (0, -1, -u), (1, 0, v), (-1, 0, -v)):
|
||||||
|
if (i + di, j + dj) in posto:
|
||||||
|
continue
|
||||||
|
prev = pts[a] + vec
|
||||||
|
dist = np.hypot(pts[:, 0] - prev[0], pts[:, 1] - prev[1])
|
||||||
|
b = int(np.argmin(dist))
|
||||||
|
if dist[b] > tol or b in indice:
|
||||||
|
continue
|
||||||
|
if not _e_angolo_x(lisc, delta, pts[b], u, v): # niente L/T del bordo
|
||||||
|
continue
|
||||||
|
posto[(i + di, j + dj)] = b
|
||||||
|
indice[b] = (i + di, j + dj)
|
||||||
|
reale = pts[b] - pts[a]
|
||||||
|
basi[b] = (reale * dj, v) if dj else (u, reale * di) # base locale aggiornata
|
||||||
|
coda.append(b)
|
||||||
|
return posto
|
||||||
|
|
||||||
|
|
||||||
|
def _rettangolo_pieno(posto: dict) -> tuple[int, int, int, int] | None:
|
||||||
|
"""Il rettangolo di indici tutto occupato di area massima: (i0, j0, righe, colonne)."""
|
||||||
|
if not posto:
|
||||||
|
return None
|
||||||
|
i_min = min(i for i, _ in posto)
|
||||||
|
j_min = min(j for _, j in posto)
|
||||||
|
nr = max(i for i, _ in posto) - i_min + 1
|
||||||
|
nc = max(j for _, j in posto) - j_min + 1
|
||||||
|
m = np.zeros((nr, nc), bool)
|
||||||
|
for (i, j) in posto:
|
||||||
|
m[i - i_min, j - j_min] = True
|
||||||
|
migliore = (0, 0, 0, 0)
|
||||||
|
alt = np.zeros(nc, int)
|
||||||
|
for r in range(nr): # istogramma delle altezze + pila, O(righe*colonne)
|
||||||
|
alt = np.where(m[r], alt + 1, 0)
|
||||||
|
pila: list[int] = []
|
||||||
|
for c in range(nc + 1):
|
||||||
|
h = int(alt[c]) if c < nc else 0
|
||||||
|
while pila and alt[pila[-1]] >= h:
|
||||||
|
altezza = int(alt[pila.pop()])
|
||||||
|
sx = pila[-1] + 1 if pila else 0
|
||||||
|
larg = c - sx
|
||||||
|
if altezza * larg > migliore[2] * migliore[3]:
|
||||||
|
migliore = (r - altezza + 1 + i_min, sx + j_min, altezza, larg)
|
||||||
|
pila.append(c)
|
||||||
|
return migliore if migliore[2] * migliore[3] else None
|
||||||
|
|
||||||
|
|
||||||
|
def _orienta(g: np.ndarray) -> np.ndarray:
|
||||||
|
"""Riordina (nr,nc,2) con colonne verso destra e righe verso il basso."""
|
||||||
|
a = (g[:, -1] - g[:, 0]).mean(axis=0) # direzione dell'indice di colonna
|
||||||
|
b = (g[-1, :] - g[0, :]).mean(axis=0) # direzione dell'indice di riga
|
||||||
|
if abs(b[0]) > abs(a[0]): # assi scambiati (scacchiera ruotata di ~90 gradi)
|
||||||
|
g = g.transpose(1, 0, 2)
|
||||||
|
a, b = b, a
|
||||||
|
if a[0] < 0:
|
||||||
|
g = g[:, ::-1]
|
||||||
|
if b[1] < 0:
|
||||||
|
g = g[::-1]
|
||||||
|
return np.ascontiguousarray(g)
|
||||||
|
|
||||||
|
|
||||||
|
def _passo_mediano(g: np.ndarray) -> float:
|
||||||
|
"""Distanza mediana (px) fra corner adiacenti."""
|
||||||
|
passi = np.concatenate([np.diff(g, axis=1).reshape(-1, 2), np.diff(g, axis=0).reshape(-1, 2)])
|
||||||
|
return float(np.median(np.hypot(passi[:, 0], passi[:, 1])))
|
||||||
|
|
||||||
|
|
||||||
|
def _raffina(img: np.ndarray, pts: np.ndarray, passo: float) -> np.ndarray:
|
||||||
|
"""cornerSubPix su (n,2) con finestra ~0.3 passo, ristretta per chi sta vicino al bordo.
|
||||||
|
|
||||||
|
Una finestra che esce dall'immagine vede il bordo replicato e sposta il corner
|
||||||
|
di ~1 px (visto sull'ultima colonna di una scacchiera a 14 px dal bordo): per
|
||||||
|
questi punti si usa la finestra piu' grande che sta dentro; sotto 3 px resta
|
||||||
|
la posizione grezza.
|
||||||
|
"""
|
||||||
|
h, w = img.shape
|
||||||
|
out = pts.astype(np.float32).copy()
|
||||||
|
win = int(min(20, max(3, 0.3 * passo)))
|
||||||
|
dentro = np.minimum.reduce([out[:, 0], w - 1 - out[:, 0], out[:, 1], h - 1 - out[:, 1]])
|
||||||
|
per_punto = np.minimum(win, np.floor(dentro).astype(int) - 2)
|
||||||
|
crit = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_COUNT, 40, 0.001)
|
||||||
|
for wi in np.unique(per_punto[per_punto >= 3]):
|
||||||
|
sel = per_punto == wi
|
||||||
|
out[sel] = cv2.cornerSubPix(img, out[sel].reshape(-1, 1, 2).copy(),
|
||||||
|
(int(wi), int(wi)), (-1, -1), crit).reshape(-1, 2)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _trova_griglia(frame: np.ndarray) -> np.ndarray:
|
||||||
|
"""Griglia di angoli (n_righe, n_colonne, 2) x,y, in ordine di riga dall'alto."""
|
||||||
|
h, w = frame.shape
|
||||||
|
migliore = None
|
||||||
|
lisc = cv2.GaussianBlur(frame.astype(np.float32), (0, 0), 1.0)
|
||||||
|
p5, p95 = np.percentile(frame, (5, 95))
|
||||||
|
delta = 0.25 * float(p95 - p5) # contrasto minimo fra quadranti opposti
|
||||||
|
for sigma in _SIGME:
|
||||||
|
pts = _candidati(frame, sigma)
|
||||||
|
posto = _cresci_reticolo(pts, (w / 2.0, h / 2.0), lisc, delta)
|
||||||
|
rett = _rettangolo_pieno(posto) if posto else None
|
||||||
|
if rett is None:
|
||||||
|
continue
|
||||||
|
i0, j0, nr, nc = rett
|
||||||
|
if nr < 3 or nc < 3:
|
||||||
|
continue
|
||||||
|
if migliore is not None and nr * nc < 0.5 * migliore[0] * migliore[1]:
|
||||||
|
break # oltre il picco: le scale piu' grosse fondono gli angoli
|
||||||
|
if migliore is None or nr * nc > migliore[0] * migliore[1]:
|
||||||
|
g = np.array([[pts[posto[(i0 + i, j0 + j)]] for j in range(nc)]
|
||||||
|
for i in range(nr)])
|
||||||
|
migliore = (nr, nc, g)
|
||||||
|
if migliore is None:
|
||||||
|
raise CalibrazioneError(
|
||||||
|
"griglia della scacchiera non trovata: serve una scacchiera nitida "
|
||||||
|
"che riempia il campo")
|
||||||
|
g = _orienta(migliore[2])
|
||||||
|
nr, nc, _ = g.shape
|
||||||
|
passo = _passo_mediano(g)
|
||||||
|
# sub-pixel: finestra proporzionale al passo, abbastanza piccola da non vedere i vicini
|
||||||
|
grezzi = g.reshape(-1, 2).astype(np.float32)
|
||||||
|
raff = _raffina(frame, grezzi, passo)
|
||||||
|
spostati = np.hypot(*(raff - grezzi).T) > 0.3 * passo
|
||||||
|
raff[spostati] = grezzi[spostati] # un raffinamento che scappa e' peggio del grezzo
|
||||||
|
return raff.reshape(nr, nc, 2).astype(np.float64)
|
||||||
|
|
||||||
|
|
||||||
|
def _estrapola(g: np.ndarray, k: int, prima: bool) -> np.ndarray:
|
||||||
|
"""Aggiunge k colonne (asse 1) per estrapolazione lineare, prima o dopo."""
|
||||||
|
if k <= 0:
|
||||||
|
return g
|
||||||
|
if prima:
|
||||||
|
passo = g[:, 0] - g[:, 1]
|
||||||
|
extra = np.stack([g[:, 0] + m * passo for m in range(k, 0, -1)], axis=1)
|
||||||
|
return np.concatenate([extra, g], axis=1)
|
||||||
|
passo = g[:, -1] - g[:, -2]
|
||||||
|
extra = np.stack([g[:, -1] + m * passo for m in range(1, k + 1)], axis=1)
|
||||||
|
return np.concatenate([g, extra], axis=1)
|
||||||
|
|
||||||
|
|
||||||
|
def _estendi_asse(g: np.ndarray, lungh: int, c: int, passo: float,
|
||||||
|
grid_step: int) -> tuple[np.ndarray, int]:
|
||||||
|
"""Estende la griglia (asse 1) fino a coprire l'immagine; ritorna (griglia, k_prima).
|
||||||
|
|
||||||
|
`c` e' la coordinata (0 = x, 1 = y) e `lungh` la dimensione dell'immagine su
|
||||||
|
quell'asse. `from_grid` (crop_domain=False) tiene costante la mappa fuori
|
||||||
|
dalla griglia: senza anelli in piu' i bordi dell'immagine raddrizzata
|
||||||
|
sarebbero sbavati.
|
||||||
|
"""
|
||||||
|
margine = max(0.0, float(g[:, 0, c].min()))
|
||||||
|
k_pre = int(math.ceil(margine / passo)) + 1
|
||||||
|
g = _estrapola(g, k_pre, True)
|
||||||
|
offset = int(g[0, 0, c]) # lo stesso int() che usa from_grid
|
||||||
|
# colonne totali perche' l'ultimo nodo, a passo grid_step, superi il bordo
|
||||||
|
servono = int(math.ceil((lungh - 1 - offset) / grid_step)) + 2
|
||||||
|
g = _estrapola(g, max(0, servono - g.shape[1]), False)
|
||||||
|
return g, k_pre
|
||||||
|
|
||||||
|
|
||||||
|
class MappaPronta(NamedTuple):
|
||||||
|
"""Mappa gia' convertita in virgola fissa per un cv2.remap veloce (#84)."""
|
||||||
|
map1: np.ndarray
|
||||||
|
map2: np.ndarray
|
||||||
|
pixel_size: float
|
||||||
|
shape: tuple # (altezza, larghezza) dei frame per cui vale
|
||||||
|
|
||||||
|
|
||||||
|
def _prepara_mappa(mappa: DistortionMap) -> MappaPronta:
|
||||||
|
# CV_16SC2: remap bilineare in virgola fissa, piu' veloce di float32 su 5 MP
|
||||||
|
m1, m2 = cv2.convertMaps(mappa.map_col, mappa.map_row, cv2.CV_16SC2)
|
||||||
|
return MappaPronta(m1, m2, float(mappa.pixel_size), mappa.shape)
|
||||||
|
|
||||||
|
|
||||||
|
def raddrizza(frame: np.ndarray, mappa: MappaPronta) -> np.ndarray | None:
|
||||||
|
"""Frame raddrizzato, o None se le dimensioni non sono quelle della calibrazione."""
|
||||||
|
if frame.shape[:2] != tuple(mappa.shape):
|
||||||
|
return None
|
||||||
|
return cv2.remap(frame, mappa.map1, mappa.map2, cv2.INTER_LINEAR,
|
||||||
|
borderMode=cv2.BORDER_CONSTANT, borderValue=0)
|
||||||
|
|
||||||
|
|
||||||
|
def calibra_scacchiera_densa(pixels, square_mm: float) -> tuple[dict, DistortionMap]:
|
||||||
|
"""Calibra da UNA foto di scacchiera densa (riempie il campo). Input: solo square_mm.
|
||||||
|
|
||||||
|
Ritorna (risultato, mappa). Risultato: {px_per_mm, mm_per_px (dell'immagine
|
||||||
|
RADDRIZZATA), rms_px, residuo_rms_px, residuo_max_px, n_corners, cols, rows,
|
||||||
|
square_mm, image_width, image_height, scale_spread_pct (0: la mappa uniforma
|
||||||
|
la scala), copertura_pct, metodo, distorsione, overlay_png (b64)}.
|
||||||
|
"""
|
||||||
|
if not (square_mm > 0 and math.isfinite(square_mm)):
|
||||||
|
raise CalibrazioneError("square_mm deve essere > 0")
|
||||||
|
frame = _gray_u8(pixels)
|
||||||
|
h, w = frame.shape
|
||||||
|
g = _trova_griglia(frame)
|
||||||
|
nr, nc, _ = g.shape
|
||||||
|
passo = _passo_mediano(g)
|
||||||
|
grid_step = int(round(passo))
|
||||||
|
if grid_step < 2:
|
||||||
|
raise CalibrazioneError("calibrazione degenere: passo della griglia troppo piccolo")
|
||||||
|
|
||||||
|
# anelli in piu' ai bordi (x e poi y), poi la mappa dai punti estesi
|
||||||
|
e, kl = _estendi_asse(g, w, 0, passo, grid_step)
|
||||||
|
et, kt = _estendi_asse(e.transpose(1, 0, 2), h, 1, passo, grid_step)
|
||||||
|
e = et.transpose(1, 0, 2)
|
||||||
|
try:
|
||||||
|
mappa = DistortionMap.from_grid(
|
||||||
|
e[..., 0].ravel(), e[..., 1].ravel(), e.shape[1], e.shape[0],
|
||||||
|
image_size=(w, h), grid_step=grid_step, crop_domain=False,
|
||||||
|
grid_step_measure=square_mm)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise CalibrazioneError(f"mappa di distorsione non costruibile: {exc}") from exc
|
||||||
|
mm_per_px = float(mappa.pixel_size)
|
||||||
|
if not (mm_per_px > 0 and math.isfinite(mm_per_px)):
|
||||||
|
raise CalibrazioneError("calibrazione degenere: scala non valida")
|
||||||
|
|
||||||
|
# qualita': si raddrizza la stessa foto e si rilegge ogni corner nel punto ideale
|
||||||
|
off_c, off_r = int(e[0, 0, 0]), int(e[0, 0, 1])
|
||||||
|
ideali = np.array([[(off_c + (kl + j) * grid_step, off_r + (kt + i) * grid_step)
|
||||||
|
for j in range(nc)] for i in range(nr)], np.float32).reshape(-1, 1, 2)
|
||||||
|
rett = cv2.remap(frame, mappa.map_col, mappa.map_row, cv2.INTER_LINEAR,
|
||||||
|
borderMode=cv2.BORDER_CONSTANT, borderValue=0)
|
||||||
|
# solo i corner ideali dentro l'immagine raddrizzata (cornerSubPix rifiuta quelli fuori)
|
||||||
|
xy = ideali.reshape(-1, 2)
|
||||||
|
dentro = (xy[:, 0] >= 1) & (xy[:, 0] <= w - 2) & (xy[:, 1] >= 1) & (xy[:, 1] <= h - 2)
|
||||||
|
if int(dentro.sum()) < 4:
|
||||||
|
raise CalibrazioneError("griglia raddrizzata fuori dall'immagine: calibrazione non verificabile")
|
||||||
|
ideali = ideali[dentro].reshape(-1, 2)
|
||||||
|
letti = _raffina(rett, ideali, grid_step)
|
||||||
|
scarto = (letti - ideali).astype(np.float64)
|
||||||
|
scarto -= np.median(scarto, axis=0) # int() dell'offset sposta tutto di una traslazione
|
||||||
|
dist = np.hypot(scarto[:, 0], scarto[:, 1])
|
||||||
|
residuo_max = float(dist.max())
|
||||||
|
residuo_rms = float(math.sqrt(float((dist ** 2).mean())))
|
||||||
|
|
||||||
|
# copertura: area del quadrilatero dei quattro angoli esterni / area immagine
|
||||||
|
quad = np.array([g[0, 0], g[0, -1], g[-1, -1], g[-1, 0]], np.float32)
|
||||||
|
copertura = float(cv2.contourArea(quad)) / float(w * h) * 100.0
|
||||||
|
|
||||||
|
# overlay: corner trovati sull'originale (ridotto a max 1600 px: 5 MP in PNG pesano)
|
||||||
|
f = min(1.0, 1600.0 / w)
|
||||||
|
bgr = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR)
|
||||||
|
pts = g.reshape(-1, 1, 2).astype(np.float32)
|
||||||
|
if f < 1.0:
|
||||||
|
bgr = cv2.resize(bgr, None, fx=f, fy=f, interpolation=cv2.INTER_AREA)
|
||||||
|
pts = pts * np.float32(f)
|
||||||
|
cv2.drawChessboardCorners(bgr, (nc, nr), pts, True)
|
||||||
|
ok, png = cv2.imencode(".png", bgr)
|
||||||
|
if not ok:
|
||||||
|
raise RuntimeError("PNG dell'overlay non codificabile")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"px_per_mm": 1.0 / mm_per_px,
|
||||||
|
"mm_per_px": mm_per_px,
|
||||||
|
"rms_px": residuo_rms,
|
||||||
|
"residuo_rms_px": residuo_rms,
|
||||||
|
"residuo_max_px": residuo_max,
|
||||||
|
"n_corners": int(nr * nc),
|
||||||
|
"cols": int(nc),
|
||||||
|
"rows": int(nr),
|
||||||
|
"square_mm": float(square_mm),
|
||||||
|
"image_width": int(w),
|
||||||
|
"image_height": int(h),
|
||||||
|
"scale_spread_pct": 0.0,
|
||||||
|
"copertura_pct": copertura,
|
||||||
|
"metodo": "densa",
|
||||||
|
"distorsione": True,
|
||||||
|
"overlay_png": base64.b64encode(png.tobytes()).decode("ascii"),
|
||||||
|
}, mappa
|
||||||
|
|
||||||
|
|
||||||
|
def ora_iso() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
class NomeNonValido(ValueError):
|
||||||
|
"""#91: nome di calibrazione vuoto, troppo lungo o con caratteri di controllo (400)."""
|
||||||
|
|
||||||
|
|
||||||
|
class NomeGiaUsato(ValueError):
|
||||||
|
"""#91: esiste gia' una calibrazione con questo nome (senza distinguere maiuscole) (409)."""
|
||||||
|
|
||||||
|
|
||||||
|
class NomeSconosciuto(KeyError):
|
||||||
|
"""#91: nessuna calibrazione con questo nome (404)."""
|
||||||
|
|
||||||
|
|
||||||
|
def pulisci_nome(nome) -> str:
|
||||||
|
"""#91: nome valido = strip, 1-60 caratteri, niente caratteri di controllo."""
|
||||||
|
if not isinstance(nome, str):
|
||||||
|
raise NomeNonValido("il nome della calibrazione deve essere un testo")
|
||||||
|
nome = nome.strip()
|
||||||
|
if not nome:
|
||||||
|
raise NomeNonValido("il nome della calibrazione è vuoto")
|
||||||
|
if len(nome) > 60:
|
||||||
|
raise NomeNonValido("il nome della calibrazione supera i 60 caratteri")
|
||||||
|
if any(unicodedata.category(c) == "Cc" for c in nome):
|
||||||
|
raise NomeNonValido("il nome della calibrazione contiene caratteri di controllo")
|
||||||
|
return nome
|
||||||
|
|
||||||
|
|
||||||
|
# #91: campi che l'elenco espone per ogni calibrazione (il resto resta nel JSON)
|
||||||
|
_CAMPI_ELENCO = ("nome", "origine", "image_width", "image_height", "px_per_mm",
|
||||||
|
"mm_per_px", "metodo", "square_mm", "calibrated_at")
|
||||||
|
|
||||||
|
|
||||||
|
class Archivio:
|
||||||
|
"""#91: archivio UNICO di calibrazioni con nome, in `cartella`.
|
||||||
|
|
||||||
|
Libreria: <cartella>/calibrazioni/<nome ripulito>-<sha1 del nome>.json (+ .npz se
|
||||||
|
densa, stesso nome). Attive: <cartella>/attive.json = {sorgente: nome}. Una
|
||||||
|
calibrazione non appartiene a una sorgente: la sorgente ne sceglie una (`attiva`).
|
||||||
|
#84: `leggi_mappa` tiene la mappa in memoria finche' il file non cambia.
|
||||||
|
Il formato di prima (un JSON per sorgente, campo "source", in <cartella>) si
|
||||||
|
migra da solo al costruttore (`migra`, idempotente).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, cartella: Path | str):
|
||||||
|
self.cartella = Path(cartella)
|
||||||
|
self._cache: dict = {} # percorso .npz -> (mtime_ns, MappaPronta)
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._lock_dati = threading.RLock() # #91: libreria e attive.json (leggi-modifica-scrivi)
|
||||||
|
self.migra()
|
||||||
|
|
||||||
|
# ---- percorsi
|
||||||
|
|
||||||
|
def _file(self, sorgente: str) -> Path:
|
||||||
|
# caratteri sicuri + hash dell'originale: due sorgenti diverse non si pestano
|
||||||
|
# (#91: oggi serve ai settaggi camera e al formato vecchio da migrare)
|
||||||
|
pulito = re.sub(r"[^A-Za-z0-9._-]+", "_", sorgente).strip("._") or "sorgente"
|
||||||
|
sigla = hashlib.sha1(sorgente.encode("utf-8")).hexdigest()[:8]
|
||||||
|
return self.cartella / f"{pulito[:60]}-{sigla}.json"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _libreria(self) -> Path:
|
||||||
|
return self.cartella / "calibrazioni"
|
||||||
|
|
||||||
|
def _file_nome(self, nome: str) -> Path:
|
||||||
|
# #91: stesso schema di _file, ma l'hash e' sul nome senza maiuscole/minuscole
|
||||||
|
pulito = re.sub(r"[^A-Za-z0-9._-]+", "_", nome).strip("._") or "calibrazione"
|
||||||
|
sigla = hashlib.sha1(nome.casefold().encode("utf-8")).hexdigest()[:8]
|
||||||
|
return self._libreria / f"{pulito[:60]}-{sigla}.json"
|
||||||
|
|
||||||
|
def _file_mappa_nome(self, nome: str) -> Path:
|
||||||
|
return self._file_nome(nome).with_suffix(".npz") # #84 stesso nome del JSON
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _file_attive(self) -> Path:
|
||||||
|
return self.cartella / "attive.json"
|
||||||
|
|
||||||
|
# ---- scrittura atomica
|
||||||
|
|
||||||
|
def _scrivi_json(self, destinazione: Path, dati: dict) -> None:
|
||||||
|
destinazione.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
# scrittura atomica: file temporaneo nella stessa cartella, poi replace
|
||||||
|
fd, tmp = tempfile.mkstemp(dir=destinazione.parent, suffix=".tmp")
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(dati, f, ensure_ascii=False, indent=2)
|
||||||
|
os.replace(tmp, destinazione)
|
||||||
|
except BaseException:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _leggi_json(self, percorso: Path) -> dict | None:
|
||||||
|
try:
|
||||||
|
dati = json.loads(percorso.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None # assente o illeggibile
|
||||||
|
return dati if isinstance(dati, dict) else None
|
||||||
|
|
||||||
|
# ---- attive
|
||||||
|
|
||||||
|
def _leggi_attive(self) -> dict:
|
||||||
|
dati = self._leggi_json(self._file_attive) or {}
|
||||||
|
return {k: v for k, v in dati.items() if isinstance(k, str) and isinstance(v, str)}
|
||||||
|
|
||||||
|
def _scrivi_attive(self, attive: dict) -> None:
|
||||||
|
self._scrivi_json(self._file_attive, attive)
|
||||||
|
|
||||||
|
# ---- migrazione dal formato vecchio
|
||||||
|
|
||||||
|
def migra(self) -> None:
|
||||||
|
"""#91: ogni <cartella>/*.json con "source" (una per sorgente) entra in libreria.
|
||||||
|
|
||||||
|
Nome = valore di "source" (gia' preso: "<source> (2)", "(3)"...); il .npz
|
||||||
|
accanto segue; la sorgente resta calibrata con quella voce (attive.json).
|
||||||
|
Idempotente: il vecchio file sparisce, un secondo giro non trova nulla.
|
||||||
|
"""
|
||||||
|
if not self.cartella.is_dir():
|
||||||
|
return
|
||||||
|
with self._lock_dati:
|
||||||
|
for vecchio in sorted(self.cartella.glob("*.json")):
|
||||||
|
if vecchio.name == self._file_attive.name:
|
||||||
|
continue
|
||||||
|
dati = self._leggi_json(vecchio)
|
||||||
|
sorgente = dati.get("source") if dati else None
|
||||||
|
if not isinstance(sorgente, str) or not sorgente.strip():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
self._migra_uno(vecchio, dati, sorgente)
|
||||||
|
except OSError:
|
||||||
|
continue # un file bloccato non ferma il servizio: riprova al prossimo avvio
|
||||||
|
|
||||||
|
def _migra_uno(self, vecchio: Path, dati: dict, sorgente: str) -> None:
|
||||||
|
base = "".join(c for c in sorgente if unicodedata.category(c) != "Cc").strip()[:60].strip() or "calibrazione"
|
||||||
|
nome, n = base, 1
|
||||||
|
while True:
|
||||||
|
esistente = self.leggi_nome(nome)
|
||||||
|
if esistente is None:
|
||||||
|
break
|
||||||
|
if (esistente.get("calibrated_at") == dati.get("calibrated_at")
|
||||||
|
and esistente.get("px_per_mm") == dati.get("px_per_mm")):
|
||||||
|
break # gia' copiata da un giro interrotto: si completa e basta
|
||||||
|
n += 1
|
||||||
|
suffisso = f" ({n})"
|
||||||
|
nome = base[:60 - len(suffisso)].rstrip() + suffisso
|
||||||
|
nuovo = {k: v for k, v in dati.items() if k != "source"}
|
||||||
|
nuovo["nome"] = nome
|
||||||
|
nuovo.setdefault("origine", sorgente) # l'origine vera non era registrata
|
||||||
|
vecchia_mappa = vecchio.with_suffix(".npz")
|
||||||
|
if vecchia_mappa.is_file(): # prima la mappa, poi il JSON che la dichiara
|
||||||
|
self._libreria.mkdir(parents=True, exist_ok=True)
|
||||||
|
os.replace(vecchia_mappa, self._file_mappa_nome(nome))
|
||||||
|
self._scrivi_json(self._file_nome(nome), nuovo)
|
||||||
|
attive = self._leggi_attive()
|
||||||
|
attive[sorgente.strip()] = nome
|
||||||
|
self._scrivi_attive(attive)
|
||||||
|
vecchio.unlink()
|
||||||
|
|
||||||
|
# ---- libreria
|
||||||
|
|
||||||
|
def leggi_nome(self, nome: str) -> dict | None:
|
||||||
|
"""#91: la calibrazione con questo nome (senza distinguere maiuscole), o None."""
|
||||||
|
try:
|
||||||
|
nome = pulisci_nome(nome)
|
||||||
|
except NomeNonValido:
|
||||||
|
return None
|
||||||
|
return self._leggi_json(self._file_nome(nome))
|
||||||
|
|
||||||
|
def _meta(self, dati: dict, attive: dict) -> dict:
|
||||||
|
meta = {k: dati.get(k) for k in _CAMPI_ELENCO}
|
||||||
|
chiave = str(dati.get("nome", "")).casefold()
|
||||||
|
meta["attiva_su"] = sorted(s for s, n in attive.items() if n.casefold() == chiave)
|
||||||
|
return meta
|
||||||
|
|
||||||
|
def elenco(self) -> list[dict]:
|
||||||
|
"""#91: metadati di tutte le calibrazioni, dalla piu' recente."""
|
||||||
|
with self._lock_dati:
|
||||||
|
attive = self._leggi_attive()
|
||||||
|
voci = []
|
||||||
|
for percorso in self._libreria.glob("*.json") if self._libreria.is_dir() else []:
|
||||||
|
dati = self._leggi_json(percorso)
|
||||||
|
if dati and isinstance(dati.get("nome"), str):
|
||||||
|
voci.append(self._meta(dati, attive))
|
||||||
|
voci.sort(key=lambda m: str(m.get("calibrated_at") or ""), reverse=True)
|
||||||
|
return voci
|
||||||
|
|
||||||
|
def metadati(self, nome: str) -> dict | None:
|
||||||
|
"""#91: i metadati di una voce (come in `elenco`), o None."""
|
||||||
|
dati = self.leggi_nome(nome)
|
||||||
|
return self._meta(dati, self._leggi_attive()) if dati else None
|
||||||
|
|
||||||
|
def salva_nome(self, nome: str, dati: dict, mappa: DistortionMap | None = None,
|
||||||
|
sovrascrivi: bool = False) -> dict:
|
||||||
|
"""#91: salva `dati` (e la mappa, se densa) con questo nome; ritorna i metadati."""
|
||||||
|
nome = pulisci_nome(nome)
|
||||||
|
destinazione = self._file_nome(nome)
|
||||||
|
with self._lock_dati:
|
||||||
|
if destinazione.exists() and not sovrascrivi:
|
||||||
|
esistente = self._leggi_json(destinazione) or {}
|
||||||
|
raise NomeGiaUsato(f"esiste già una calibrazione «{esistente.get('nome', nome)}»")
|
||||||
|
corpo = {k: v for k, v in dati.items() if k not in ("source", "overlay_png", "saved")}
|
||||||
|
corpo["nome"] = nome
|
||||||
|
self._libreria.mkdir(parents=True, exist_ok=True)
|
||||||
|
# #84: prima la mappa (o la rimozione di una vecchia), poi il JSON che la dichiara
|
||||||
|
if mappa is not None:
|
||||||
|
self._salva_mappa(nome, mappa)
|
||||||
|
else:
|
||||||
|
self._togli_mappa(nome) # voce planare: una vecchia mappa non vale piu'
|
||||||
|
self._scrivi_json(destinazione, corpo)
|
||||||
|
return self._meta(corpo, self._leggi_attive())
|
||||||
|
|
||||||
|
def _salva_mappa(self, nome: str, mappa: DistortionMap) -> None:
|
||||||
|
destinazione = self._file_mappa_nome(nome)
|
||||||
|
fd, tmp = tempfile.mkstemp(dir=destinazione.parent, suffix=".tmp.npz") # .npz: numpy non aggiunge nulla
|
||||||
|
os.close(fd)
|
||||||
|
try:
|
||||||
|
mappa.save(tmp)
|
||||||
|
os.replace(tmp, destinazione) # atomica come il JSON
|
||||||
|
except BaseException:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
with self._lock:
|
||||||
|
self._cache.pop(str(destinazione), None)
|
||||||
|
|
||||||
|
def _togli_mappa(self, nome: str) -> None:
|
||||||
|
destinazione = self._file_mappa_nome(nome)
|
||||||
|
try:
|
||||||
|
destinazione.unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
with self._lock:
|
||||||
|
self._cache.pop(str(destinazione), None)
|
||||||
|
|
||||||
|
def elimina_nome(self, nome: str) -> bool:
|
||||||
|
"""#91: toglie la voce (JSON e mappa) e la scollega da ogni sorgente. False se non c'era."""
|
||||||
|
dati = self.leggi_nome(nome)
|
||||||
|
if dati is None:
|
||||||
|
return False
|
||||||
|
canonico = str(dati.get("nome", nome))
|
||||||
|
with self._lock_dati:
|
||||||
|
try:
|
||||||
|
self._file_nome(canonico).unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
self._togli_mappa(canonico)
|
||||||
|
attive = self._leggi_attive()
|
||||||
|
restanti = {s: n for s, n in attive.items() if n.casefold() != canonico.casefold()}
|
||||||
|
if restanti != attive:
|
||||||
|
self._scrivi_attive(restanti)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ---- sorgenti
|
||||||
|
|
||||||
|
def attiva(self, sorgente: str, nome: str) -> str:
|
||||||
|
"""#91: la sorgente usa questa calibrazione; ritorna il nome com'e' in libreria."""
|
||||||
|
dati = self.leggi_nome(nome)
|
||||||
|
if dati is None:
|
||||||
|
raise NomeSconosciuto(nome)
|
||||||
|
canonico = str(dati["nome"])
|
||||||
|
with self._lock_dati:
|
||||||
|
attive = self._leggi_attive()
|
||||||
|
attive[sorgente] = canonico
|
||||||
|
self._scrivi_attive(attive)
|
||||||
|
return canonico
|
||||||
|
|
||||||
|
def scollega(self, sorgente: str) -> None:
|
||||||
|
"""#91: la sorgente non ha piu' una calibrazione attiva; la libreria resta."""
|
||||||
|
with self._lock_dati:
|
||||||
|
attive = self._leggi_attive()
|
||||||
|
if sorgente in attive:
|
||||||
|
del attive[sorgente]
|
||||||
|
self._scrivi_attive(attive)
|
||||||
|
|
||||||
|
def attiva_di(self, sorgente: str) -> str | None:
|
||||||
|
"""#91: il nome della calibrazione attiva della sorgente (se esiste ancora)."""
|
||||||
|
nome = self._leggi_attive().get(sorgente)
|
||||||
|
dati = self.leggi_nome(nome) if nome else None
|
||||||
|
return str(dati["nome"]) if dati else None
|
||||||
|
|
||||||
|
def leggi(self, sorgente: str) -> dict | None:
|
||||||
|
"""#91: la calibrazione ATTIVA della sorgente, con "nome" e "source"; None se nessuna."""
|
||||||
|
nome = self.attiva_di(sorgente)
|
||||||
|
dati = self.leggi_nome(nome) if nome else None
|
||||||
|
return {**dati, "source": sorgente} if dati else None
|
||||||
|
|
||||||
|
def leggi_mappa(self, sorgente: str) -> MappaPronta | None:
|
||||||
|
"""La mappa della calibrazione attiva, pronta per `raddrizza`; None se non ne ha una."""
|
||||||
|
nome = self.attiva_di(sorgente) # #91: la mappa e' della voce attiva, non della sorgente
|
||||||
|
if nome is None:
|
||||||
|
return None
|
||||||
|
percorso = self._file_mappa_nome(nome)
|
||||||
|
chiave = str(percorso)
|
||||||
|
try:
|
||||||
|
mtime = percorso.stat().st_mtime_ns
|
||||||
|
except OSError:
|
||||||
|
with self._lock:
|
||||||
|
self._cache.pop(chiave, None)
|
||||||
|
return None
|
||||||
|
with self._lock:
|
||||||
|
voce = self._cache.get(chiave)
|
||||||
|
if voce is not None and voce[0] == mtime:
|
||||||
|
return voce[1]
|
||||||
|
try:
|
||||||
|
pronta = _prepara_mappa(DistortionMap.load(percorso))
|
||||||
|
except (OSError, ValueError, KeyError):
|
||||||
|
return None # illeggibile = nessuna correzione
|
||||||
|
with self._lock:
|
||||||
|
self._cache[chiave] = (mtime, pronta)
|
||||||
|
return pronta
|
||||||
File diff suppressed because it is too large
Load Diff
+391
-24
@@ -4,19 +4,75 @@ Cosa fa: espone via HTTP l'acquisizione di una camera (vs-camera: GigE/GenTL,
|
|||||||
OpenCV, cartella di immagini) con lo stesso contratto di `vs-gui`, ma solo
|
OpenCV, cartella di immagini) con lo stesso contratto di `vs-gui`, ma solo
|
||||||
`grab` (copiato da TieVision-Studio, src/visionsuite/gui/server.py):
|
`grab` (copiato da TieVision-Studio, src/visionsuite/gui/server.py):
|
||||||
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,
|
||||||
|
grezzo=false} -> PNG
|
||||||
|
#84: se la sorgente ha una mappa di distorsione salvata il frame esce
|
||||||
|
raddrizzato (header X-Raddrizzato: si|no); "grezzo": true lo dà com'è.
|
||||||
Errori: 400 parametri, 404 sorgente inesistente, 503 guasto camera, 504 timeout.
|
Errori: 400 parametri, 404 sorgente inesistente, 503 guasto camera, 504 timeout.
|
||||||
|
|
||||||
|
Calibrazione a scacchiera (#83, logica in scripts/calibrazione.py).
|
||||||
|
#91: archivio UNICO di calibrazioni con nome; una sorgente ne ha una attiva:
|
||||||
|
POST /camera/calibrate multipart: source (obbligatorio solo senza image),
|
||||||
|
square_mm, image (file, opzionale: senza, grab GREZZO dalla sorgente),
|
||||||
|
cols/rows (opzionali). NON salva mai (il vecchio campo save e' ignorato).
|
||||||
|
#84: SENZA cols/rows = calibrazione densa (default): una foto di
|
||||||
|
scacchiera che riempie il campo, griglia trovata da sola, mappa di
|
||||||
|
distorsione; mm/px e px/mm sono quelli dell'immagine RADDRIZZATA. CON
|
||||||
|
cols/rows (angoli interni) = metodo planare di prima. Risposta: i campi
|
||||||
|
del calcolo + overlay_png (b64), calibrated_at, origine ("camera:<src>" |
|
||||||
|
"file:<nome>"), calcolo_id (il servizio tiene in memoria gli ultimi 8
|
||||||
|
calcoli) e metodo ("densa"|"planare"), distorsione (bool) e, per la
|
||||||
|
densa, residuo_rms_px, residuo_max_px, copertura_pct;
|
||||||
|
422 scacchiera non trovata o parametri invalidi
|
||||||
|
GET /camera/calibrazioni[?source=S] -> {calibrazioni: [{nome, origine,
|
||||||
|
image_width, image_height, px_per_mm, mm_per_px, metodo, square_mm,
|
||||||
|
calibrated_at, attiva_su}], attiva: nome|null} (dalla piu' recente)
|
||||||
|
POST /camera/calibrazioni json {calcolo_id, nome, sovrascrivi=false} -> 201
|
||||||
|
metadati. 404 calcolo scaduto, 400 nome non valido, 409 nome gia' usato
|
||||||
|
DELETE /camera/calibrazioni?nome=X -> 204 (toglie anche la mappa e la scollega
|
||||||
|
da ogni sorgente) | 404
|
||||||
|
PUT /camera/calibrazione-attiva json {source, nome}: legge un frame dalla
|
||||||
|
sorgente e carica la calibrazione solo se la risoluzione (larghezza x
|
||||||
|
altezza) coincide: 409 altrimenti; 404 nome ignoto; 200 come GET sotto
|
||||||
|
GET /camera/calibration?source=<src> -> 200 la calibrazione ATTIVA {source,
|
||||||
|
nome, px_per_mm, mm_per_px, ..., image_width, image_height,
|
||||||
|
calibrated_at} | 404 "sorgente non calibrata" (lo legge il backend)
|
||||||
|
DELETE /camera/calibration?source=<src> -> 204 (SCOLLEGA la calibrazione attiva,
|
||||||
|
la libreria resta)
|
||||||
|
GET /calibrazione -> pagina HTML autonoma (offline) per fare tutto a mano
|
||||||
|
GET /acquisizione -> stessa pagina (#87)
|
||||||
|
Acquisizione (#87): anteprima, esposizione/guadagno, settaggi di default:
|
||||||
|
POST /camera/anteprima json {source, grezzo=false, lato_max=1280 (320..4096),
|
||||||
|
timeout_ms=5000} -> JPEG ridotto (lato lungo <= lato_max, mai ingrandito).
|
||||||
|
Header: X-Raddrizzato si|no, X-Larghezza/X-Altezza (frame PIENO),
|
||||||
|
X-Nitidezza (varianza del Laplaciano), X-Saturati-Pct (% pixel >= 250),
|
||||||
|
X-Media (livello medio 0-255), tutti sul frame pieno in grigio.
|
||||||
|
GET /camera/parametri?source=<src> -> {source, regolabile, parametri:
|
||||||
|
{esposizione, esposizione_auto, guadagno, guadagno_auto: dominio}};
|
||||||
|
dominio = GenicamSource.dominio (valore, tipo, min/max/passo o scelte).
|
||||||
|
Esposizione in us, guadagno in dB. Sorgente senza capacita': regolabile=false.
|
||||||
|
PUT /camera/parametri json {source, valori: {nome: valore}} -> come GET.
|
||||||
|
Scrive prima gli *_auto, poi esposizione/guadagno (assesta=3).
|
||||||
|
400 nome ignoto o corpo errato, 409 non regolabile, 422 camera rifiuta
|
||||||
|
POST /camera/parametri/salva json {source} -> {source, salvati, file}
|
||||||
|
scrive <config-dir>/settaggi/<nome>-<sha1>.json; alla prima apertura
|
||||||
|
della camera nel servizio il file, se c'e', viene ricaricato.
|
||||||
|
Le calibrazioni stanno in <config-dir>/calibrazioni, le attive in attive.json (default
|
||||||
|
%ProgramData%/Tielogic/TieMeasureFlow/acquisitore su Windows, ./acquisitore altrove).
|
||||||
|
|
||||||
Serve perche' vs-gui ascolta solo su 127.0.0.1 e il container `client` non lo
|
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,18 +80,36 @@ camera a chiunque raggiunga la porta: solo su rete isolata.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
import threading
|
import threading
|
||||||
|
import uuid # #91 id dei calcoli in attesa di nome
|
||||||
|
from collections import OrderedDict
|
||||||
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, CameraStateError
|
||||||
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",)
|
||||||
|
_MAX_CALCOLI = 8 # #91: calcoli tenuti in memoria in attesa di un nome
|
||||||
|
_log = logging.getLogger("camera_service") # #87: errori non bloccanti (settaggi)
|
||||||
|
# #87: i quattro parametri regolabili, nell'ordine di presentazione
|
||||||
|
_PARAMETRI = ("esposizione", "esposizione_auto", "guadagno", "guadagno_auto")
|
||||||
|
# #87: in scrittura prima gli automatismi (un valore manuale con auto acceso viene riscritto)
|
||||||
|
_ORDINE_SCRITTURA = ("esposizione_auto", "guadagno_auto", "esposizione", "guadagno")
|
||||||
|
|
||||||
# errori noti di vs-camera (inglese) -> italiano; esatti, poi per prefisso
|
# errori noti di vs-camera (inglese) -> italiano; esatti, poi per prefisso
|
||||||
_MESSAGGI = {
|
_MESSAGGI = {
|
||||||
@@ -61,10 +135,23 @@ 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
|
||||||
|
calcoli: OrderedDict = OrderedDict() # #91: calcolo_id -> (dati, mappa); gli ultimi _MAX_CALCOLI
|
||||||
|
lock_calcoli = threading.Lock()
|
||||||
|
|
||||||
def _config() -> dict:
|
def _config() -> dict:
|
||||||
if folder is None:
|
if folder is None:
|
||||||
@@ -95,6 +182,52 @@ def create_app(folder: Path | str | None = None) -> FastAPI:
|
|||||||
return {"sources": [{"name": v.name, "kind": v.kind, "label": v.label}
|
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 _apri(nome: str):
|
||||||
|
"""#87: sorgente aperta (o da aprire); da chiamare SOLO sotto _lock_di(nome)."""
|
||||||
|
with lock:
|
||||||
|
sorgente = aperte.get(nome)
|
||||||
|
if sorgente is None:
|
||||||
|
sorgente = open_source(nome, None, config=_config())
|
||||||
|
# TieVision-Core#341 / #89: free-run, serve la scena di adesso, non il frame vecchio in coda
|
||||||
|
if hasattr(sorgente, "solo_ultimo_frame"):
|
||||||
|
try:
|
||||||
|
sorgente.solo_ultimo_frame()
|
||||||
|
except Exception as e: # noqa: BLE001 - producer senza il nodo: si apre lo stesso
|
||||||
|
_log.warning("frame piu' recente non impostabile su %s: %s", nome, e)
|
||||||
|
_carica_settaggi(nome, sorgente) # #87: predefiniti salvati, se ci sono
|
||||||
|
with lock:
|
||||||
|
aperte[nome] = sorgente
|
||||||
|
return sorgente
|
||||||
|
|
||||||
|
def _file_settaggi(nome: str) -> Path:
|
||||||
|
# #87: stesso schema di nome della calibrazione, in una sottocartella
|
||||||
|
return archivio.cartella / "settaggi" / archivio._file(nome).name
|
||||||
|
|
||||||
|
def _carica_settaggi(nome: str, sorgente) -> None:
|
||||||
|
percorso = _file_settaggi(nome)
|
||||||
|
if not percorso.is_file() or not hasattr(sorgente, "carica_settaggi"):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
sorgente.carica_settaggi(percorso)
|
||||||
|
except Exception as e: # noqa: BLE001 - un file rotto non blocca l'apertura
|
||||||
|
_log.warning("settaggi di %s non caricati (%s): %s", nome, percorso, e)
|
||||||
|
|
||||||
|
def _con_sorgente(nome: str, fn):
|
||||||
|
"""#87: fn(sorgente) sotto il lock della sorgente; errori camera -> HTTP."""
|
||||||
|
try:
|
||||||
|
with _lock_di(nome): # solo questa sorgente; le altre non aspettano
|
||||||
|
return fn(_apri(nome))
|
||||||
|
except SourceNotFound as e:
|
||||||
|
raise HTTPException(404, _traduci(str(e)))
|
||||||
|
except TimeoutError as e:
|
||||||
|
raise HTTPException(504, _traduci(str(e)) or "timeout")
|
||||||
|
except (CameraGrabError, CameraError) as e:
|
||||||
|
raise HTTPException(503, _traduci(str(e)))
|
||||||
|
|
||||||
|
def _grab(nome: str, timeout: int):
|
||||||
|
"""Un frame dalla sorgente (apre e tiene aperta); errori camera -> HTTP."""
|
||||||
|
return _con_sorgente(nome, lambda s: s.grab(timeout)) # #87: corpo in _con_sorgente
|
||||||
|
|
||||||
@app.post("/camera/grab")
|
@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
|
||||||
@@ -104,28 +237,260 @@ def create_app(folder: Path | str | None = None) -> FastAPI:
|
|||||||
trigger = corpo.get("trigger", "continuo")
|
trigger = corpo.get("trigger", "continuo")
|
||||||
if trigger not in _TRIGGER:
|
if trigger not in _TRIGGER:
|
||||||
raise HTTPException(400, "«trigger» supportato: solo continuo")
|
raise HTTPException(400, "«trigger» supportato: solo continuo")
|
||||||
|
grezzo = corpo.get("grezzo", False) # #84: true = frame non raddrizzato
|
||||||
|
if not isinstance(grezzo, bool):
|
||||||
|
raise HTTPException(400, "«grezzo» deve essere true o false")
|
||||||
timeout = corpo.get("timeout_ms", 5000)
|
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
|
raddrizzato = False
|
||||||
with lock:
|
if not grezzo: # #84: sorgente calibrata (densa) -> frame raddrizzato dalla mappa in cache (#91: quella attiva)
|
||||||
sorgente = aperte.get(nome)
|
mappa = archivio.leggi_mappa(nome)
|
||||||
if sorgente is None:
|
if mappa is not None:
|
||||||
sorgente = open_source(nome, None, config=_config())
|
corretto = calibrazione.raddrizza(frame, mappa)
|
||||||
with lock:
|
if corretto is not None: # None = dimensioni diverse da quelle calibrate
|
||||||
aperte[nome] = sorgente
|
frame, raddrizzato = corretto, True
|
||||||
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",
|
||||||
|
headers={"X-Raddrizzato": "si" if raddrizzato else "no"})
|
||||||
|
|
||||||
|
# ---- #83: calibrazione a scacchiera per sorgente
|
||||||
|
|
||||||
|
def _sorgente(valore) -> str:
|
||||||
|
if not isinstance(valore, str) or not valore.strip():
|
||||||
|
raise HTTPException(400, "manca «source»")
|
||||||
|
return valore.strip()
|
||||||
|
|
||||||
|
@app.get("/camera/calibration")
|
||||||
|
def calibrazione_leggi(source: str = ""):
|
||||||
|
dati = archivio.leggi(_sorgente(source)) # #91: la calibrazione ATTIVA della sorgente
|
||||||
|
if dati is None:
|
||||||
|
raise HTTPException(404, "sorgente non calibrata")
|
||||||
|
return dati
|
||||||
|
|
||||||
|
@app.delete("/camera/calibration", status_code=204)
|
||||||
|
def calibrazione_elimina(source: str = ""):
|
||||||
|
archivio.scollega(_sorgente(source)) # #91: scollega soltanto, la libreria resta
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
@app.post("/camera/calibrate")
|
||||||
|
async def calibrazione_calcola(
|
||||||
|
source: str | None = Form(None), # #91: serve solo se manca l'immagine
|
||||||
|
square_mm: float = Form(...),
|
||||||
|
cols: int | None = Form(None), # #84: opzionali, senza = calibrazione densa
|
||||||
|
rows: int | None = Form(None),
|
||||||
|
image: UploadFile | None = File(None),
|
||||||
|
save: bool = Form(False), # #91: ignorato, il calcolo non salva mai (si salva con nome)
|
||||||
|
):
|
||||||
|
contenuto = await image.read() if image is not None else b""
|
||||||
|
if contenuto:
|
||||||
|
frame = cv2.imdecode(np.frombuffer(contenuto, np.uint8), cv2.IMREAD_UNCHANGED)
|
||||||
|
if frame is None:
|
||||||
|
raise HTTPException(422, "l'immagine caricata non è leggibile")
|
||||||
|
origine = "file:" + (Path((image.filename or "").replace("\\", "/")).name or "immagine")
|
||||||
|
else:
|
||||||
|
nome = _sorgente(source)
|
||||||
|
frame = await run_in_threadpool(_grab, nome, 5000) # come /camera/grab (404/503/504)
|
||||||
|
origine = nome
|
||||||
|
if (cols is None) != (rows is None):
|
||||||
|
raise HTTPException(422, "cols e rows vanno indicati insieme (o nessuno dei due)")
|
||||||
|
mappa = None
|
||||||
|
try:
|
||||||
|
if cols is None: # #84: densa, nessuna griglia da dichiarare
|
||||||
|
risultato, mappa = await run_in_threadpool(
|
||||||
|
calibrazione.calibra_scacchiera_densa, frame, square_mm)
|
||||||
|
else: # metodo planare di #83, invariato
|
||||||
|
risultato = calibrazione.calibra_scacchiera(frame, cols, rows, square_mm)
|
||||||
|
except calibrazione.CalibrazioneError as e:
|
||||||
|
raise HTTPException(422, str(e))
|
||||||
|
corpo = {**{k: v for k, v in risultato.items() if k != "overlay_png"},
|
||||||
|
"calibrated_at": calibrazione.ora_iso(), "origine": origine}
|
||||||
|
calcolo_id = uuid.uuid4().hex
|
||||||
|
with lock_calcoli: # #91: il calcolo resta in memoria finche' non gli si da' un nome
|
||||||
|
calcoli[calcolo_id] = (corpo, mappa)
|
||||||
|
while len(calcoli) > _MAX_CALCOLI:
|
||||||
|
calcoli.popitem(last=False)
|
||||||
|
return {**corpo, "calcolo_id": calcolo_id, "overlay_png": risultato["overlay_png"]}
|
||||||
|
|
||||||
|
# ---- #91: archivio unico di calibrazioni con nome
|
||||||
|
|
||||||
|
@app.get("/camera/calibrazioni")
|
||||||
|
def calibrazioni_elenco(source: str | None = None):
|
||||||
|
attiva = archivio.attiva_di(_sorgente(source)) if source is not None else None
|
||||||
|
return {"calibrazioni": archivio.elenco(), "attiva": attiva}
|
||||||
|
|
||||||
|
@app.post("/camera/calibrazioni", status_code=201)
|
||||||
|
def calibrazioni_salva(corpo: dict = Body(default=None)):
|
||||||
|
corpo = corpo if isinstance(corpo, dict) else {}
|
||||||
|
calcolo_id = corpo.get("calcolo_id")
|
||||||
|
if not isinstance(calcolo_id, str) or not calcolo_id:
|
||||||
|
raise HTTPException(400, "manca «calcolo_id»")
|
||||||
|
sovrascrivi = corpo.get("sovrascrivi", False)
|
||||||
|
if not isinstance(sovrascrivi, bool):
|
||||||
|
raise HTTPException(400, "«sovrascrivi» deve essere true o false")
|
||||||
|
try:
|
||||||
|
nome = calibrazione.pulisci_nome(corpo.get("nome"))
|
||||||
|
except calibrazione.NomeNonValido as e:
|
||||||
|
raise HTTPException(400, str(e))
|
||||||
|
with lock_calcoli:
|
||||||
|
voce = calcoli.get(calcolo_id)
|
||||||
|
if voce is None:
|
||||||
|
raise HTTPException(404, "calcolo non più disponibile: ricalcolare")
|
||||||
|
dati, mappa = voce
|
||||||
|
try:
|
||||||
|
return archivio.salva_nome(nome, dati, mappa, sovrascrivi)
|
||||||
|
except calibrazione.NomeGiaUsato as e:
|
||||||
|
raise HTTPException(409, str(e))
|
||||||
|
|
||||||
|
@app.delete("/camera/calibrazioni", status_code=204)
|
||||||
|
def calibrazioni_elimina(nome: str = ""):
|
||||||
|
if not archivio.elimina_nome(nome):
|
||||||
|
raise HTTPException(404, "calibrazione non trovata")
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
@app.put("/camera/calibrazione-attiva")
|
||||||
|
def calibrazione_attiva(corpo: dict = Body(default=None)):
|
||||||
|
corpo = corpo if isinstance(corpo, dict) else {}
|
||||||
|
sorgente = _sorgente(corpo.get("source"))
|
||||||
|
richiesto = corpo.get("nome")
|
||||||
|
dati = archivio.leggi_nome(richiesto) if isinstance(richiesto, str) else None
|
||||||
|
if dati is None:
|
||||||
|
raise HTTPException(404, "calibrazione non trovata")
|
||||||
|
nome = dati["nome"]
|
||||||
|
frame = _grab(sorgente, 5000) # stesso lock e stessi errori di /camera/grab (404/503/504)
|
||||||
|
alt, larg = frame.shape[:2]
|
||||||
|
cw, ch = dati.get("image_width"), dati.get("image_height")
|
||||||
|
if not isinstance(cw, int) or not isinstance(ch, int):
|
||||||
|
raise HTTPException(409, f"la calibrazione «{nome}» non ha la risoluzione registrata: "
|
||||||
|
"non si può caricare")
|
||||||
|
if (cw, ch) != (larg, alt): # la mappa vale solo per frame della stessa risoluzione
|
||||||
|
raise HTTPException(409, f"la calibrazione «{nome}» è per immagini {cw}×{ch}, "
|
||||||
|
f"la sorgente dà {larg}×{alt}: non si può caricare")
|
||||||
|
archivio.attiva(sorgente, nome)
|
||||||
|
return archivio.leggi(sorgente)
|
||||||
|
|
||||||
|
# ---- #87: acquisizione (anteprima, parametri, settaggi)
|
||||||
|
|
||||||
|
def _regolabile(s) -> bool:
|
||||||
|
# #87: ha il vocabolario delle capacita' e ne supporta almeno una delle quattro
|
||||||
|
if not (hasattr(s, "supporta") and hasattr(s, "dominio")):
|
||||||
|
return False
|
||||||
|
return any(s.supporta(n) for n in _PARAMETRI)
|
||||||
|
|
||||||
|
def _parametri(nome: str, s) -> dict:
|
||||||
|
"""#87: forma di GET /camera/parametri; da chiamare sotto lock."""
|
||||||
|
parametri: dict = {}
|
||||||
|
if _regolabile(s):
|
||||||
|
for n in _PARAMETRI:
|
||||||
|
if not s.supporta(n):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
parametri[n] = s.dominio(n)
|
||||||
|
except CameraStateError as e: # capacita' dichiarata ma non leggibile: si salta
|
||||||
|
_log.warning("dominio di %s su %s non letto: %s", n, nome, e)
|
||||||
|
return {"source": nome, "regolabile": bool(parametri), "parametri": parametri}
|
||||||
|
|
||||||
|
def _grigio8(frame):
|
||||||
|
g = frame if frame.ndim == 2 else cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
||||||
|
if g.dtype == np.uint16: # #87: 16 bit -> 8 bit, per soglia e media coerenti
|
||||||
|
g = (g >> 8).astype(np.uint8)
|
||||||
|
elif g.dtype != np.uint8:
|
||||||
|
g = cv2.convertScaleAbs(g, alpha=255.0 / max(float(g.max()), 1.0))
|
||||||
|
return g
|
||||||
|
|
||||||
|
@app.post("/camera/anteprima")
|
||||||
|
def camera_anteprima(corpo: dict = Body(default=None)):
|
||||||
|
corpo = corpo if isinstance(corpo, dict) else {}
|
||||||
|
nome = _sorgente(corpo.get("source"))
|
||||||
|
grezzo = corpo.get("grezzo", False)
|
||||||
|
if not isinstance(grezzo, bool):
|
||||||
|
raise HTTPException(400, "«grezzo» deve essere true o false")
|
||||||
|
lato = corpo.get("lato_max", 1280)
|
||||||
|
if isinstance(lato, bool) or not isinstance(lato, int) or not 320 <= lato <= 4096:
|
||||||
|
raise HTTPException(400, "«lato_max» deve essere un intero fra 320 e 4096")
|
||||||
|
timeout = corpo.get("timeout_ms", 5000)
|
||||||
|
if isinstance(timeout, bool) or not isinstance(timeout, int) or not 1 <= timeout <= 10000:
|
||||||
|
raise HTTPException(400, "«timeout_ms» deve essere un intero fra 1 e 10000")
|
||||||
|
frame = _grab(nome, timeout)
|
||||||
|
raddrizzato = False
|
||||||
|
if not grezzo: # come /camera/grab
|
||||||
|
mappa = archivio.leggi_mappa(nome)
|
||||||
|
if mappa is not None:
|
||||||
|
corretto = calibrazione.raddrizza(frame, mappa)
|
||||||
|
if corretto is not None:
|
||||||
|
frame, raddrizzato = corretto, True
|
||||||
|
alt, larg = frame.shape[:2]
|
||||||
|
grigio = _grigio8(frame) # misure sul frame PIENO, non sul ridotto
|
||||||
|
nitidezza = float(cv2.Laplacian(grigio, cv2.CV_64F).var())
|
||||||
|
saturati = float((grigio >= 250).mean() * 100.0)
|
||||||
|
media = float(grigio.mean())
|
||||||
|
if frame.dtype != np.uint8: # il JPEG vuole 8 bit
|
||||||
|
frame = grigio
|
||||||
|
scala = lato / max(alt, larg)
|
||||||
|
if scala < 1.0: # mai ingrandita
|
||||||
|
frame = cv2.resize(frame, (max(1, round(larg * scala)), max(1, round(alt * scala))),
|
||||||
|
interpolation=cv2.INTER_AREA)
|
||||||
|
ok, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(500, "codifica JPEG fallita")
|
||||||
|
return Response(buf.tobytes(), media_type="image/jpeg", headers={
|
||||||
|
"X-Raddrizzato": "si" if raddrizzato else "no",
|
||||||
|
"X-Larghezza": str(larg), "X-Altezza": str(alt),
|
||||||
|
"X-Nitidezza": f"{nitidezza:.1f}", "X-Saturati-Pct": f"{saturati:.2f}",
|
||||||
|
"X-Media": f"{media:.1f}"})
|
||||||
|
|
||||||
|
@app.get("/camera/parametri")
|
||||||
|
def parametri_leggi(source: str = ""):
|
||||||
|
nome = _sorgente(source)
|
||||||
|
return _con_sorgente(nome, lambda s: _parametri(nome, s))
|
||||||
|
|
||||||
|
@app.put("/camera/parametri")
|
||||||
|
def parametri_scrivi(corpo: dict = Body(default=None)):
|
||||||
|
corpo = corpo if isinstance(corpo, dict) else {}
|
||||||
|
nome = _sorgente(corpo.get("source"))
|
||||||
|
valori = corpo.get("valori")
|
||||||
|
if not isinstance(valori, dict) or not valori:
|
||||||
|
raise HTTPException(400, "manca «valori»")
|
||||||
|
for n, v in valori.items():
|
||||||
|
if n not in _PARAMETRI:
|
||||||
|
raise HTTPException(400, f"parametro non riconosciuto: «{n}»")
|
||||||
|
if isinstance(v, bool) or not isinstance(v, (int, float, str)):
|
||||||
|
raise HTTPException(400, f"valore non valido per «{n}»")
|
||||||
|
|
||||||
|
def scrivi(s):
|
||||||
|
if not _regolabile(s):
|
||||||
|
raise HTTPException(409, "questa sorgente non ha parametri regolabili")
|
||||||
|
for n in _ORDINE_SCRITTURA: # prima gli *_auto
|
||||||
|
if n in valori:
|
||||||
|
try:
|
||||||
|
s.scrivi(n, valori[n], assesta=3)
|
||||||
|
except CameraStateError as e:
|
||||||
|
raise HTTPException(422, _traduci(str(e)))
|
||||||
|
return _parametri(nome, s) # valori riletti dalla camera
|
||||||
|
|
||||||
|
return _con_sorgente(nome, scrivi)
|
||||||
|
|
||||||
|
@app.post("/camera/parametri/salva")
|
||||||
|
def parametri_salva(corpo: dict = Body(default=None)):
|
||||||
|
nome = _sorgente(corpo.get("source") if isinstance(corpo, dict) else None)
|
||||||
|
|
||||||
|
def salva(s):
|
||||||
|
if not _regolabile(s) or not hasattr(s, "salva_settaggi"):
|
||||||
|
raise HTTPException(409, "questa sorgente non ha parametri regolabili")
|
||||||
|
percorso = _file_settaggi(nome)
|
||||||
|
percorso.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
s.salva_settaggi(percorso)
|
||||||
|
return {"source": nome, "salvati": True, "file": percorso.name}
|
||||||
|
|
||||||
|
return _con_sorgente(nome, salva)
|
||||||
|
|
||||||
|
@app.get("/calibrazione", response_class=HTMLResponse)
|
||||||
|
@app.get("/acquisizione", response_class=HTMLResponse) # #87: stessa pagina
|
||||||
|
def calibrazione_pagina():
|
||||||
|
return HTMLResponse(PAGINA)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
@@ -136,8 +501,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__":
|
||||||
|
|||||||
@@ -0,0 +1,458 @@
|
|||||||
|
"""Seed per il PC della fiera: utenti, stazione, tre ricette e quattro settimane di misure.
|
||||||
|
|
||||||
|
Cosa fa
|
||||||
|
Popola un database TieMeasureFlow (SQLite del pacchetto Windows) cosi' che al primo
|
||||||
|
avvio NON compaia la procedura di setup e la demo abbia gia' qualcosa da mostrare:
|
||||||
|
|
||||||
|
- utenti adriano (admin), maker, capoturno, operatore (password nella costante
|
||||||
|
UTENTI qui sotto, decise da Adriano per la fiera);
|
||||||
|
- la stazione ST-DEFAULT (quella del pacchetto, vedi packaging/windows/avvio.py)
|
||||||
|
con camera_url http://127.0.0.1:8765 e camera_source camera:601020 (Itala);
|
||||||
|
- FIERA-FLANGIA «Flangia Ø80» (5 quote) e FIERA-ALBERO «Albero rettificato»
|
||||||
|
(4 quote), con circa 4 settimane di storico fino a ieri (lun-ven 8-17, una
|
||||||
|
produzione chiusa per giorno, lotti e seriali coerenti, misure con Cpk diversi
|
||||||
|
per quota, una deriva lenta nell'ultima settimana, una quota con 1-3% fuori
|
||||||
|
tolleranza);
|
||||||
|
- FIERA-DXF «Profilo DXF (visione)»: un task dxf_compare senza ricetta di visione
|
||||||
|
(si crea in fiera dal pannello «Crea ricetta da DXF»).
|
||||||
|
|
||||||
|
«Setup fatto» per il backend vuol dire: esiste almeno un utente admin
|
||||||
|
(api/routers/setup.py::_admin_exists), quindi basta creare adriano.
|
||||||
|
|
||||||
|
Come si usa (da daitarn, TMF fermo sul PC, su una COPIA del file SQLite)
|
||||||
|
uv run python scripts/seed_fiera.py --db C:/percorso/tmf.db [--replace] [--dry-run]
|
||||||
|
[--fine AAAA-MM-GG]
|
||||||
|
|
||||||
|
Il file deve avere gia' lo schema (alembic upgrade head: lo fa l'exe al primo
|
||||||
|
avvio). --replace toglie solo i dati FIERA-* (ricette, misure, produzioni) e li
|
||||||
|
rifa'; senza, un secondo giro non duplica le ricette. Gli utenti e la stazione
|
||||||
|
sono idempotenti: se esistono si aggiornano password, ruoli e camera.
|
||||||
|
--dry-run fa tutto e annulla (stampa i numeri). Il seme casuale e' fisso: stesso
|
||||||
|
comando e stessa data di fine, stessi dati.
|
||||||
|
|
||||||
|
Da cosa dipende
|
||||||
|
src.backend (modelli ORM, auth_service, measurement_service), SQLAlchemy +
|
||||||
|
aiosqlite, tzdata su Windows (gia' in pyproject). Non usa MySQL.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import random
|
||||||
|
import sys
|
||||||
|
from datetime import date, datetime, time, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from sqlalchemy import delete, event, select
|
||||||
|
from sqlalchemy.ext.asyncio import (
|
||||||
|
AsyncSession, async_sessionmaker, create_async_engine,
|
||||||
|
)
|
||||||
|
|
||||||
|
# La radice del repo nel path, cosi' "src.backend" si importa lanciando lo script da ovunque
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from src.backend.database import async_session_factory # noqa: E402
|
||||||
|
from src.backend.models.orm.measurement import Measurement # noqa: E402
|
||||||
|
from src.backend.models.orm.production import ( # noqa: E402
|
||||||
|
ProductionEvent, ProductionRun,
|
||||||
|
)
|
||||||
|
from src.backend.models.orm.recipe import Recipe, RecipeVersion # noqa: E402
|
||||||
|
from src.backend.models.orm.setting import RecipeVersionAudit # noqa: E402
|
||||||
|
from src.backend.models.orm.station import ( # noqa: E402
|
||||||
|
Station, StationRecipeAssignment,
|
||||||
|
)
|
||||||
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask # noqa: E402
|
||||||
|
from src.backend.models.orm.task_result import TaskResult # noqa: E402
|
||||||
|
from src.backend.models.orm.user import User # noqa: E402
|
||||||
|
from src.backend.services import auth_service # noqa: E402
|
||||||
|
from src.backend.services.measurement_service import calculate_pass_fail # noqa: E402
|
||||||
|
|
||||||
|
CODES = ("FIERA-FLANGIA", "FIERA-ALBERO", "FIERA-DXF")
|
||||||
|
STATION_CODE = "ST-DEFAULT" # STATION_CODE del pacchetto Windows (avvio.py PREDEFINITI)
|
||||||
|
CAMERA_URL = "http://127.0.0.1:8765"
|
||||||
|
CAMERA_SOURCE = "camera:601020" # Itala sul PC fiera
|
||||||
|
SEED = 20261026 # seme fisso: dati ripetibili
|
||||||
|
FUSO = ZoneInfo("Europe/Rome") # le misure sono salvate in UTC naive, l'orario di turno e' locale
|
||||||
|
GIORNI_STORICO = 28 # circa 4 settimane
|
||||||
|
|
||||||
|
# (username, password, nome, ruoli, admin). Password "123" per tutti, chiesta da Adriano per la fiera (#85); il login non controlla la lunghezza.
|
||||||
|
UTENTI = [
|
||||||
|
("adriano", "123", "Adriano Dal Pastro",
|
||||||
|
["Maker", "MeasurementTec", "Metrologist", "Supervisor"], True),
|
||||||
|
("maker", "123", "Marco Ferrari (Maker)", ["Maker"], False),
|
||||||
|
("capoturno", "123", "Luca Bianchi (Capoturno)",
|
||||||
|
["Supervisor", "MeasurementTec"], False),
|
||||||
|
("operatore", "123", "Giulia Rossi (Operatore)", ["MeasurementTec"], False),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _quota(marker, descrizione, nominale, tol, unita="mm", dec=3):
|
||||||
|
"""Quota simmetrica: tolleranza = nominale +/- tol, warning al 70% della tolleranza."""
|
||||||
|
return dict(
|
||||||
|
marker_number=marker, description=descrizione, nominal=nominale,
|
||||||
|
ltl=round(nominale - tol, dec), lwl=round(nominale - 0.7 * tol, dec),
|
||||||
|
uwl=round(nominale + 0.7 * tol, dec), utl=round(nominale + tol, dec),
|
||||||
|
unit=unita,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Profilo statistico di una quota: scostamento medio (in frazioni di tol), sigma (in
|
||||||
|
# frazioni di tol) e deriva (frazione di tol aggiunta linearmente nell'ultima settimana).
|
||||||
|
# Cpk ~ (1 - |scostamento|) / (3 * sigma): sigma 0.2 -> 1,67; 0.33 -> 1,0.
|
||||||
|
FLANGIA = [
|
||||||
|
# (task, quota, scostamento, sigma, deriva)
|
||||||
|
(0, _quota(1, "Diametro esterno Ø80", 80.0, 0.05), 0.0, 0.20, 0.0), # Cpk ~1,67 capace
|
||||||
|
(0, _quota(2, "Diametro foro centrale Ø25", 25.0, 0.02), 0.0, 0.33, 0.0), # Cpk ~1,0 al limite
|
||||||
|
(1, _quota(3, "Spessore flangia", 12.0, 0.10), 0.0, 0.22, 0.55), # deriva ultima settimana
|
||||||
|
(1, _quota(4, "Interasse fori Ø60", 60.0, 0.10), 0.10, 0.25, 0.0), # Cpk ~1,2
|
||||||
|
(1, _quota(5, "Profondita' lamatura", 8.0, 0.15), 0.50, 0.244, 0.0), # ~2% fuori tolleranza
|
||||||
|
]
|
||||||
|
ALBERO = [
|
||||||
|
(0, _quota(1, "Diametro cuscinetto Ø20 h6", 20.0, 0.010), 0.0, 0.20, 0.0), # Cpk ~1,67
|
||||||
|
(0, _quota(2, "Diametro sede Ø25 j5", 25.0, 0.008), 0.0, 0.25, 0.0), # Cpk ~1,33
|
||||||
|
(1, _quota(3, "Lunghezza totale", 150.0, 0.10), 0.0, 0.33, 0.0), # Cpk ~1,0
|
||||||
|
(1, _quota(4, "Rugosita' Ra", 0.40, 0.20, unita="µm"), 0.20, 0.25, 0.0), # Cpk ~1,07
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _ora_utc(giorno: date, minuti_da_mezzanotte: int) -> datetime:
|
||||||
|
"""Ora locale di officina -> datetime UTC naive, come le salva il backend."""
|
||||||
|
locale = datetime.combine(giorno, time(0, 0), tzinfo=FUSO) + timedelta(minutes=minuti_da_mezzanotte)
|
||||||
|
return locale.astimezone(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
def _giorni_lavorativi(fine: date) -> list[date]:
|
||||||
|
"""Lun-ven degli ultimi GIORNI_STORICO giorni, fino a `fine` compreso."""
|
||||||
|
giorni = [fine - timedelta(days=i) for i in range(GIORNI_STORICO)]
|
||||||
|
return sorted(g for g in giorni if g.weekday() < 5)
|
||||||
|
|
||||||
|
|
||||||
|
def _slot_giornalieri(rng: random.Random, n: int) -> list[int]:
|
||||||
|
"""n orari (minuti dalla mezzanotte) in 8-12 e 13-17, ogni ~30 min con un po' di scarto."""
|
||||||
|
mezz_ore = [8 * 60 + 30 * i for i in range(8)] + [13 * 60 + 30 * i for i in range(8)]
|
||||||
|
scelti = sorted(rng.sample(mezz_ore, n))
|
||||||
|
return [m + rng.randint(0, 9) for m in scelti]
|
||||||
|
|
||||||
|
|
||||||
|
async def _upsert_utenti(session: AsyncSession) -> dict[str, User]:
|
||||||
|
"""Crea gli utenti o, se esistono, ne aggiorna password, nome, ruoli e flag admin."""
|
||||||
|
utenti = {}
|
||||||
|
for username, password, nome, ruoli, admin in UTENTI:
|
||||||
|
esistente = (await session.execute(
|
||||||
|
select(User).where(User.username == username)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if esistente is None:
|
||||||
|
utenti[username] = await auth_service.create_user(
|
||||||
|
session, username=username, password=password, display_name=nome,
|
||||||
|
roles=ruoli, is_admin=admin,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
esistente.password_hash = auth_service.hash_password(password)
|
||||||
|
esistente.display_name = nome
|
||||||
|
esistente.roles = ruoli
|
||||||
|
esistente.is_admin = admin
|
||||||
|
esistente.active = True
|
||||||
|
utenti[username] = esistente
|
||||||
|
await session.flush()
|
||||||
|
return utenti
|
||||||
|
|
||||||
|
|
||||||
|
async def _upsert_stazione(session: AsyncSession, owner: User) -> Station:
|
||||||
|
"""La stazione del pacchetto, con la camera Itala; se c'e' gia' ne aggiorna la camera."""
|
||||||
|
stazione = (await session.execute(
|
||||||
|
select(Station).where(Station.code == STATION_CODE)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if stazione is None:
|
||||||
|
stazione = Station(
|
||||||
|
code=STATION_CODE, name="Postazione fiera", location="Stand",
|
||||||
|
created_by=owner.id,
|
||||||
|
)
|
||||||
|
session.add(stazione)
|
||||||
|
stazione.camera_url = CAMERA_URL
|
||||||
|
stazione.camera_source = CAMERA_SOURCE
|
||||||
|
await session.flush()
|
||||||
|
return stazione
|
||||||
|
|
||||||
|
|
||||||
|
async def _conta_e_cancella(session: AsyncSession, ricette: list[Recipe]) -> dict:
|
||||||
|
"""Toglie le ricette FIERA-* e tutto cio' che e' misurato contro di esse. Ritorna i conteggi."""
|
||||||
|
conti = {"misure": 0, "produzioni": 0, "eventi": 0}
|
||||||
|
for ricetta in ricette:
|
||||||
|
versioni = (await session.execute(
|
||||||
|
select(RecipeVersion.id).where(RecipeVersion.recipe_id == ricetta.id)
|
||||||
|
)).scalars().all()
|
||||||
|
runs = (await session.execute(
|
||||||
|
select(ProductionRun.id).where(ProductionRun.recipe_id == ricetta.id)
|
||||||
|
)).scalars().all()
|
||||||
|
if versioni:
|
||||||
|
conti["misure"] += (await session.execute(
|
||||||
|
delete(Measurement).where(Measurement.version_id.in_(versioni))
|
||||||
|
)).rowcount
|
||||||
|
await session.execute( # esiti dei task di queste versioni (FK sulle versioni)
|
||||||
|
delete(TaskResult).where(TaskResult.version_id.in_(versioni))
|
||||||
|
)
|
||||||
|
if runs:
|
||||||
|
conti["eventi"] += (await session.execute(
|
||||||
|
delete(ProductionEvent).where(ProductionEvent.run_id.in_(runs))
|
||||||
|
)).rowcount
|
||||||
|
conti["produzioni"] += (await session.execute(
|
||||||
|
delete(ProductionRun).where(ProductionRun.id.in_(runs))
|
||||||
|
)).rowcount
|
||||||
|
await session.execute(
|
||||||
|
delete(StationRecipeAssignment).where(StationRecipeAssignment.recipe_id == ricetta.id)
|
||||||
|
)
|
||||||
|
await session.execute(
|
||||||
|
delete(RecipeVersionAudit).where(RecipeVersionAudit.recipe_id == ricetta.id)
|
||||||
|
)
|
||||||
|
# Versioni, task e quote se ne vanno con la cascade ORM della ricetta.
|
||||||
|
await session.delete(ricetta)
|
||||||
|
await session.flush()
|
||||||
|
return conti
|
||||||
|
|
||||||
|
|
||||||
|
async def _nuova_ricetta(session, owner, stazione, *, code, name, description,
|
||||||
|
interval, requires_serial, note_versione):
|
||||||
|
"""Ricetta + versione 1 corrente + assegnazione alla stazione."""
|
||||||
|
ricetta = Recipe(
|
||||||
|
code=code, name=name, description=description, created_by=owner.id, active=True,
|
||||||
|
measurement_interval_minutes=interval, requires_lot=True,
|
||||||
|
requires_serial=requires_serial, allow_manual_input=True,
|
||||||
|
)
|
||||||
|
session.add(ricetta)
|
||||||
|
await session.flush()
|
||||||
|
versione = RecipeVersion(
|
||||||
|
recipe_id=ricetta.id, version_number=1, is_current=True,
|
||||||
|
created_by=owner.id, change_notes=note_versione,
|
||||||
|
)
|
||||||
|
session.add(versione)
|
||||||
|
session.add(StationRecipeAssignment(
|
||||||
|
station_id=stazione.id, recipe_id=ricetta.id, assigned_by=owner.id,
|
||||||
|
))
|
||||||
|
await session.flush()
|
||||||
|
return ricetta, versione
|
||||||
|
|
||||||
|
|
||||||
|
async def _task(session, versione, ordine, titolo, tipo, direttiva=None,
|
||||||
|
descrizione=None, quote=()):
|
||||||
|
task = RecipeTask(
|
||||||
|
version_id=versione.id, order_index=ordine, title=titolo, task_type=tipo,
|
||||||
|
directive=direttiva, description=descrizione,
|
||||||
|
)
|
||||||
|
session.add(task)
|
||||||
|
await session.flush()
|
||||||
|
sottotask = []
|
||||||
|
for q in quote:
|
||||||
|
st = RecipeSubtask(task_id=task.id, **q)
|
||||||
|
session.add(st)
|
||||||
|
sottotask.append(st)
|
||||||
|
await session.flush()
|
||||||
|
return task, sottotask
|
||||||
|
|
||||||
|
|
||||||
|
async def _ricetta_meccanica(session, owner, stazione, *, code, name, description,
|
||||||
|
interval, requires_serial, profilo, titoli_task):
|
||||||
|
"""Ricetta con una nota iniziale e un task di misura per ogni gruppo di quote del profilo."""
|
||||||
|
ricetta, versione = await _nuova_ricetta(
|
||||||
|
session, owner, stazione, code=code, name=name, description=description,
|
||||||
|
interval=interval, requires_serial=requires_serial,
|
||||||
|
note_versione="Ricetta dimostrativa per la fiera",
|
||||||
|
)
|
||||||
|
await _task(session, versione, 0, "Preparazione del pezzo", "note",
|
||||||
|
direttiva="Prima di misurare",
|
||||||
|
descrizione="Pulire il pezzo e controllare che sia a **temperatura ambiente**.")
|
||||||
|
sottotask_per_quota = {}
|
||||||
|
for indice, titolo in enumerate(titoli_task):
|
||||||
|
quote = [q for (t, q, *_r) in profilo if t == indice]
|
||||||
|
_t, sottotask = await _task(
|
||||||
|
session, versione, indice + 1, titolo, "measure",
|
||||||
|
direttiva="Misurare le quote in sequenza", quote=quote,
|
||||||
|
)
|
||||||
|
for st in sottotask:
|
||||||
|
sottotask_per_quota[st.marker_number] = st
|
||||||
|
return ricetta, versione, sottotask_per_quota
|
||||||
|
|
||||||
|
|
||||||
|
def _valore(rng, quota, scost, sigma, deriva, frazione_tempo):
|
||||||
|
"""Un valore: normale attorno a nominale + scostamento (+ deriva nell'ultima settimana)."""
|
||||||
|
tol = quota["utl"] - quota["nominal"]
|
||||||
|
media = quota["nominal"] + tol * (scost + deriva * max(0.0, (frazione_tempo - 0.75) / 0.25))
|
||||||
|
return round(rng.gauss(media, tol * sigma), 6)
|
||||||
|
|
||||||
|
|
||||||
|
async def _storico(session, rng, fine, owner_ricetta, stazione, utenti, ricetta, versione,
|
||||||
|
sottotask, profilo, pezzi_giorno, prefisso_lotto, prefisso_seriale):
|
||||||
|
"""Una produzione chiusa per giorno lavorativo, con i suoi pezzi, eventi e misure."""
|
||||||
|
giorni = _giorni_lavorativi(fine)
|
||||||
|
operatore, capoturno = utenti["operatore"], utenti["capoturno"]
|
||||||
|
task_ids = {} # marker -> id del task di misura
|
||||||
|
for marker, st in sottotask.items():
|
||||||
|
task_ids[marker] = st.task_id
|
||||||
|
ordine_task = sorted(set(task_ids.values()))
|
||||||
|
ultimo_task = ordine_task[-1]
|
||||||
|
contatore = 0
|
||||||
|
totale = 0
|
||||||
|
for indice_giorno, giorno in enumerate(giorni):
|
||||||
|
frazione = indice_giorno / max(1, len(giorni) - 1) # 0 = primo giorno, 1 = ieri
|
||||||
|
n = rng.randint(*pezzi_giorno)
|
||||||
|
slot = _slot_giornalieri(rng, n)
|
||||||
|
lotto = f"{prefisso_lotto}{giorno:%y%m%d}"
|
||||||
|
inizio, fine_run = _ora_utc(giorno, slot[0] - 5), _ora_utc(giorno, slot[-1] + 10)
|
||||||
|
run = ProductionRun(
|
||||||
|
station_id=stazione.id, recipe_id=ricetta.id, version_id=versione.id,
|
||||||
|
operator_id=operatore.id, lot_number=lotto, status="closed",
|
||||||
|
measurement_interval_minutes=ricetta.measurement_interval_minutes,
|
||||||
|
cycle_count=n, started_at=inizio, closed_at=fine_run, closed_by=capoturno.id,
|
||||||
|
active_station_id=None,
|
||||||
|
)
|
||||||
|
session.add(run)
|
||||||
|
await session.flush()
|
||||||
|
session.add(ProductionEvent(run_id=run.id, event_type="start", user_id=operatore.id,
|
||||||
|
created_at=inizio))
|
||||||
|
for numero, minuti in enumerate(slot, start=1):
|
||||||
|
contatore += 1
|
||||||
|
seriale = f"{prefisso_seriale}{contatore:05d}"
|
||||||
|
istante = _ora_utc(giorno, minuti)
|
||||||
|
for marker in sorted(sottotask):
|
||||||
|
st = sottotask[marker]
|
||||||
|
_t, quota, scost, sigma, deriva = next(p for p in profilo if p[1]["marker_number"] == marker)
|
||||||
|
valore = _valore(rng, quota, scost, sigma, deriva, frazione)
|
||||||
|
esito, scarto = calculate_pass_fail(valore, st)
|
||||||
|
m = Measurement(
|
||||||
|
subtask_id=st.id, version_id=versione.id, measured_by=operatore.id,
|
||||||
|
value=valore, pass_fail=esito, deviation=scarto, lot_number=lotto,
|
||||||
|
serial_number=seriale,
|
||||||
|
input_method="usb_caliper" if rng.random() < 0.75 else "manual",
|
||||||
|
input_duration_ms=rng.randint(2500, 9000),
|
||||||
|
production_run_id=run.id,
|
||||||
|
# un fuori tolleranza passa solo con l'ok del capoturno (come in produzione)
|
||||||
|
supervisor_id=capoturno.id if esito == "fail" else None,
|
||||||
|
authorised_at=istante + timedelta(minutes=1) if esito == "fail" else None,
|
||||||
|
measured_at=istante + timedelta(seconds=20 * marker),
|
||||||
|
)
|
||||||
|
session.add(m)
|
||||||
|
totale += 1
|
||||||
|
# un task_measured per ogni task di misura non finale, poi il ciclo completato
|
||||||
|
for tid in ordine_task:
|
||||||
|
tipo = "cycle_completed" if tid == ultimo_task else "task_measured"
|
||||||
|
session.add(ProductionEvent(
|
||||||
|
run_id=run.id, event_type=tipo, user_id=operatore.id, task_id=tid,
|
||||||
|
created_at=istante + timedelta(minutes=1),
|
||||||
|
))
|
||||||
|
session.add(ProductionEvent(run_id=run.id, event_type="close", user_id=capoturno.id,
|
||||||
|
supervisor_id=capoturno.id, created_at=fine_run))
|
||||||
|
await session.flush()
|
||||||
|
return contatore, totale
|
||||||
|
|
||||||
|
|
||||||
|
async def _ricetta_dxf(session, owner, stazione):
|
||||||
|
"""Demo visione: un task dxf_compare, senza grafo (si crea in fiera dal pannello DXF)."""
|
||||||
|
ricetta, versione = await _nuova_ricetta(
|
||||||
|
session, owner, stazione, code="FIERA-DXF", name="Profilo DXF (visione)",
|
||||||
|
description="Confronto di un profilo con il disegno DXF tramite la camera. "
|
||||||
|
"La ricetta di visione si crea in fiera dal pannello «Crea ricetta da DXF».",
|
||||||
|
interval=None, requires_serial=False, note_versione="Ricetta dimostrativa per la fiera",
|
||||||
|
)
|
||||||
|
await _task(session, versione, 0, "Preparazione del pezzo", "note",
|
||||||
|
direttiva="Prima di misurare",
|
||||||
|
descrizione="Appoggiare il pezzo sul piano della camera, **in piano**.")
|
||||||
|
await _task(
|
||||||
|
session, versione, 1, "Confronto profilo con DXF", "dxf_compare",
|
||||||
|
direttiva="Acquisire e confrontare",
|
||||||
|
descrizione="Lo scostamento massimo del profilo rispetto al DXF deve restare entro la tolleranza.",
|
||||||
|
quote=[_quota(1, "Scostamento massimo profilo", 0.0, 0.15)],
|
||||||
|
)
|
||||||
|
return ricetta
|
||||||
|
|
||||||
|
|
||||||
|
async def seed(replace: bool, dry_run: bool, fine: date) -> int:
|
||||||
|
"""Esegue il seed in una sola transazione; con dry_run annulla tutto alla fine."""
|
||||||
|
rng = random.Random(SEED)
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
utenti = await _upsert_utenti(session)
|
||||||
|
owner = utenti["adriano"]
|
||||||
|
stazione = await _upsert_stazione(session, owner)
|
||||||
|
|
||||||
|
esistenti = list((await session.execute(
|
||||||
|
select(Recipe).where(Recipe.code.in_(CODES))
|
||||||
|
)).scalars().all())
|
||||||
|
if esistenti and not replace:
|
||||||
|
print(f"Ricette FIERA gia' presenti ({', '.join(r.code for r in esistenti)}): "
|
||||||
|
"non duplico. Usa --replace per rifarle.")
|
||||||
|
else:
|
||||||
|
if esistenti:
|
||||||
|
conti = await _conta_e_cancella(session, esistenti)
|
||||||
|
print(f"--replace: rimosse {', '.join(r.code for r in esistenti)} "
|
||||||
|
f"({conti['misure']} misure, {conti['produzioni']} produzioni, "
|
||||||
|
f"{conti['eventi']} eventi)")
|
||||||
|
|
||||||
|
r1, v1, sub1 = await _ricetta_meccanica(
|
||||||
|
session, owner, stazione, code="FIERA-FLANGIA", name="Flangia Ø80",
|
||||||
|
description="Flangia tornita in alluminio: diametri, spessore, interasse fori "
|
||||||
|
"e profondita' della lamatura. Lotto e seriale obbligatori.",
|
||||||
|
interval=30, requires_serial=True, profilo=FLANGIA,
|
||||||
|
titoli_task=["Misure diametrali", "Spessore e posizione fori"],
|
||||||
|
)
|
||||||
|
n1, m1 = await _storico(session, rng, fine, owner, stazione, utenti, r1, v1, sub1,
|
||||||
|
FLANGIA, (10, 14), "LF", "FL")
|
||||||
|
r2, v2, sub2 = await _ricetta_meccanica(
|
||||||
|
session, owner, stazione, code="FIERA-ALBERO", name="Albero rettificato",
|
||||||
|
description="Albero in acciaio rettificato: sedi dei cuscinetti, lunghezza e "
|
||||||
|
"rugosita'. Tolleranze strette.",
|
||||||
|
interval=30, requires_serial=False, profilo=ALBERO,
|
||||||
|
titoli_task=["Diametri rettificati", "Lunghezza e rugosita'"],
|
||||||
|
)
|
||||||
|
n2, m2 = await _storico(session, rng, fine, owner, stazione, utenti, r2, v2, sub2,
|
||||||
|
ALBERO, (8, 11), "LA", "AL")
|
||||||
|
await _ricetta_dxf(session, owner, stazione)
|
||||||
|
print(f"FIERA-FLANGIA: {n1} pezzi, {m1} misure; FIERA-ALBERO: {n2} pezzi, {m2} misure")
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
await session.rollback()
|
||||||
|
print("--dry-run: nessuna modifica salvata.")
|
||||||
|
else:
|
||||||
|
await session.commit()
|
||||||
|
print(f"Seed fiera completato (stazione {stazione.code}, utenti: "
|
||||||
|
f"{', '.join(u[0] for u in UTENTI)}).")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _factory_per_file(db: Path):
|
||||||
|
"""Sessioni async su un file SQLite qualsiasi, con le stesse PRAGMA dell'app (FK attive)."""
|
||||||
|
engine = create_async_engine(f"sqlite+aiosqlite:///{db.resolve().as_posix()}", echo=False)
|
||||||
|
|
||||||
|
@event.listens_for(engine.sync_engine, "connect")
|
||||||
|
def _pragma(dbapi_connection, _record) -> None:
|
||||||
|
cur = dbapi_connection.cursor()
|
||||||
|
cur.execute("PRAGMA foreign_keys=ON")
|
||||||
|
cur.execute("PRAGMA busy_timeout=5000")
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
return engine, async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def _main_async(args) -> int:
|
||||||
|
global async_session_factory
|
||||||
|
engine, async_session_factory = _factory_per_file(args.db)
|
||||||
|
try:
|
||||||
|
fine = date.fromisoformat(args.fine) if args.fine else date.today() - timedelta(days=1)
|
||||||
|
return await seed(args.replace, args.dry_run, fine)
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
parser.add_argument("--db", required=True, type=Path, help="file SQLite con lo schema gia' creato")
|
||||||
|
parser.add_argument("--replace", action="store_true",
|
||||||
|
help="rimuove i dati FIERA-* (ricette, misure, produzioni) e li rifa'")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="esegue tutto e annulla")
|
||||||
|
parser.add_argument("--fine", default=None, help="ultimo giorno dello storico AAAA-MM-GG (default: ieri)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
if not args.db.is_file():
|
||||||
|
print(f"File non trovato: {args.db}")
|
||||||
|
return 2
|
||||||
|
return asyncio.run(_main_async(args))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
"""Test della calibrazione dell'acquisitore (#83): logica pura e rotte di camera_service.
|
||||||
|
|
||||||
|
Uso: dalla radice, `uv run --python 3.13 --extra vision-worker --extra dev
|
||||||
|
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q`
|
||||||
|
(o il venv di VisionSuite come per test_camera_service.py).
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
SCRIPTS = Path(__file__).resolve().parents[1]
|
||||||
|
MODULO = SCRIPTS / "camera_service.py"
|
||||||
|
|
||||||
|
|
||||||
|
def _carica():
|
||||||
|
spec = importlib.util.spec_from_file_location("camera_service", MODULO)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
def una_scacchiera(square_px: int = 40, nx: int = 10, ny: int = 7, margin: int = 60) -> np.ndarray:
|
||||||
|
"""nx x ny quadrati => (nx-1) x (ny-1) angoli interni, su fondo bianco."""
|
||||||
|
img = np.full((ny * square_px + 2 * margin, nx * square_px + 2 * margin), 255, np.uint8)
|
||||||
|
for j in range(ny):
|
||||||
|
for i in range(nx):
|
||||||
|
if (i + j) % 2 == 0:
|
||||||
|
y, x = margin + j * square_px, margin + i * square_px
|
||||||
|
img[y:y + square_px, x:x + square_px] = 0
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def una_scacchiera_densa(w: int = 640, h: int = 480, square_px: int = 40, k: float = 1e-8) -> np.ndarray:
|
||||||
|
"""#84: scacchiera che riempie tutto il campo, con distorsione a barilotto (k sui px dal centro)."""
|
||||||
|
m = 3 * square_px # la scacchiera continua oltre il campo: nessun bordo finto nell'immagine
|
||||||
|
yy, xx = np.mgrid[0:h + 2 * m, 0:w + 2 * m]
|
||||||
|
piana = (((xx // square_px) + (yy // square_px)) % 2 * 255).astype(np.uint8)
|
||||||
|
cx, cy = w / 2.0, h / 2.0
|
||||||
|
dx, dy = (xx[m:m + h, m:m + w] - m - cx).astype(np.float32), (yy[m:m + h, m:m + w] - m - cy).astype(np.float32)
|
||||||
|
f = 1 + k * (dx * dx + dy * dy)
|
||||||
|
img = cv2.remap(piana, cx + m + dx * f, cy + m + dy * f, cv2.INTER_LINEAR)
|
||||||
|
return cv2.GaussianBlur(img, (3, 3), 0.8) # bordi non perfettamente netti, come in camera
|
||||||
|
|
||||||
|
|
||||||
|
def residuo_reticolo(griglia: np.ndarray) -> float:
|
||||||
|
"""Residuo massimo (px) della griglia (nr,nc,2) da un reticolo affine regolare."""
|
||||||
|
nr, nc, _ = griglia.shape
|
||||||
|
ii, jj = np.mgrid[0:nr, 0:nc]
|
||||||
|
A = np.stack([np.ones(nr * nc), ii.ravel(), jj.ravel()], axis=1)
|
||||||
|
P = griglia.reshape(-1, 2)
|
||||||
|
coef, *_ = np.linalg.lstsq(A, P, rcond=None)
|
||||||
|
return float(np.hypot(*(A @ coef - P).T).max())
|
||||||
|
|
||||||
|
|
||||||
|
def _png(img: np.ndarray) -> bytes:
|
||||||
|
ok, buf = cv2.imencode(".png", img)
|
||||||
|
assert ok
|
||||||
|
return buf.tobytes()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def cartella(tmp_path):
|
||||||
|
# la sorgente camera:folder fa il loop su una scacchiera: il grab la trova
|
||||||
|
cartella = tmp_path / "immagini"
|
||||||
|
cartella.mkdir()
|
||||||
|
cv2.imwrite(str(cartella / "scacchiera.png"), una_scacchiera())
|
||||||
|
return cartella
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(cartella, tmp_path):
|
||||||
|
mod = _carica()
|
||||||
|
with TestClient(mod.create_app(cartella, config_dir=tmp_path / "config")) as c:
|
||||||
|
c.config = tmp_path / "config"
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
CAMPI = {"cols": "9", "rows": "6", "square_mm": "2"}
|
||||||
|
CHIAVI_C1 = {"px_per_mm", "mm_per_px", "rms_px", "n_corners", "cols", "rows",
|
||||||
|
"square_mm", "image_width", "image_height", "scale_spread_pct", "calibrated_at"}
|
||||||
|
# #84: campi in piu' per metodo planare (C2 con cols/rows) e per la densa
|
||||||
|
CHIAVI_PLANARE = CHIAVI_C1 | {"metodo", "distorsione", "origine"} # #91: + origine ("camera:X" | "file:Y")
|
||||||
|
CHIAVI_DENSA = CHIAVI_PLANARE | {"residuo_rms_px", "residuo_max_px", "copertura_pct"}
|
||||||
|
SORGENTE = "camera:folder"
|
||||||
|
# #91: GET /camera/calibration = voce di libreria + "nome" + "source" (la sorgente che la usa)
|
||||||
|
CHIAVI_ATTIVA = {"source", "nome"}
|
||||||
|
|
||||||
|
|
||||||
|
def salva_con_nome(client, calcolo: dict, nome: str, **extra):
|
||||||
|
"""#91: POST /camera/calibrazioni sul calcolo_id di una risposta di /camera/calibrate."""
|
||||||
|
return client.post("/camera/calibrazioni", json={"calcolo_id": calcolo["calcolo_id"], "nome": nome, **extra})
|
||||||
|
|
||||||
|
|
||||||
|
def carica_su(client, sorgente: str, nome: str):
|
||||||
|
"""#91: PUT /camera/calibrazione-attiva."""
|
||||||
|
return client.put("/camera/calibrazione-attiva", json={"source": sorgente, "nome": nome})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def cartella_densa(tmp_path):
|
||||||
|
# #84: la sorgente camera:folder fa il loop su una scacchiera densa distorta
|
||||||
|
cartella = tmp_path / "immagini_dense"
|
||||||
|
cartella.mkdir()
|
||||||
|
cv2.imwrite(str(cartella / "densa.png"), una_scacchiera_densa(k=1e-7))
|
||||||
|
return cartella
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client_denso(cartella_densa, tmp_path):
|
||||||
|
mod = _carica()
|
||||||
|
with TestClient(mod.create_app(cartella_densa, config_dir=tmp_path / "config_densa")) as c:
|
||||||
|
c.config = tmp_path / "config_densa"
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
NON_IMMAGINE = b"questo non e una immagine"
|
||||||
|
|
||||||
|
|
||||||
|
# ---- logica pura
|
||||||
|
|
||||||
|
def test_scacchiera_sintetica_da_la_scala():
|
||||||
|
_carica() # mette scripts/ in sys.path
|
||||||
|
import calibrazione
|
||||||
|
r = calibrazione.calibra_scacchiera(una_scacchiera(), 9, 6, 2.0)
|
||||||
|
assert r["px_per_mm"] == pytest.approx(20.0, rel=0.005)
|
||||||
|
assert r["mm_per_px"] == pytest.approx(0.05, rel=0.005)
|
||||||
|
assert r["rms_px"] < 0.5
|
||||||
|
assert r["n_corners"] == 54
|
||||||
|
assert (r["image_width"], r["image_height"]) == (520, 400)
|
||||||
|
assert r["scale_spread_pct"] < 1.0
|
||||||
|
assert base64.b64decode(r["overlay_png"])[:8] == b"\x89PNG\r\n\x1a\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_immagine_bianca_solleva():
|
||||||
|
_carica()
|
||||||
|
import calibrazione
|
||||||
|
with pytest.raises(calibrazione.CalibrazioneError, match="non trovata"):
|
||||||
|
calibrazione.calibra_scacchiera(np.full((300, 300), 255, np.uint8), 9, 6, 2.0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("cols,rows,quadrato", [(1, 6, 2.0), (9, 6, 0.0), (9, 6, -1.0)])
|
||||||
|
def test_parametri_invalidi_sollevano(cols, rows, quadrato):
|
||||||
|
_carica()
|
||||||
|
import calibrazione
|
||||||
|
with pytest.raises(calibrazione.CalibrazioneError):
|
||||||
|
calibrazione.calibra_scacchiera(una_scacchiera(), cols, rows, quadrato)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- rotte
|
||||||
|
|
||||||
|
def test_c1_non_calibrata_404(client):
|
||||||
|
r = client.get("/camera/calibration", params={"source": "camera:folder"})
|
||||||
|
assert r.status_code == 404
|
||||||
|
assert r.json()["detail"] == "sorgente non calibrata"
|
||||||
|
|
||||||
|
|
||||||
|
def test_c1_senza_source_400(client):
|
||||||
|
assert client.get("/camera/calibration").status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_c2_immagine_caricata_senza_salvare(client):
|
||||||
|
r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI},
|
||||||
|
files={"image": ("s.png", _png(una_scacchiera()), "image/png")})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
corpo = r.json()
|
||||||
|
assert CHIAVI_PLANARE | {"overlay_png", "calcolo_id"} == set(corpo) # #91: niente "saved", niente "source"
|
||||||
|
assert corpo["metodo"] == "planare" and corpo["distorsione"] is False # #84: con cols/rows nulla cambia
|
||||||
|
assert corpo["origine"] == "file:s.png"
|
||||||
|
assert corpo["px_per_mm"] == pytest.approx(20.0, rel=0.005)
|
||||||
|
# il calcolo non salva mai
|
||||||
|
assert client.get("/camera/calibration", params={"source": "camera:folder"}).status_code == 404
|
||||||
|
assert not client.config.exists() or not list(client.config.iterdir())
|
||||||
|
|
||||||
|
|
||||||
|
def test_c2_save_true_viene_ignorato(client):
|
||||||
|
# #91: il vecchio campo save non salva piu' nulla
|
||||||
|
r = client.post("/camera/calibrate", data={"source": "camera:folder", "save": "true", **CAMPI})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert client.get("/camera/calibration", params={"source": "camera:folder"}).status_code == 404
|
||||||
|
assert client.get("/camera/calibrazioni").json()["calibrazioni"] == []
|
||||||
|
assert not client.config.exists() or not list(client.config.iterdir())
|
||||||
|
|
||||||
|
|
||||||
|
def test_c2_c1_c3_ciclo_con_grab_dalla_sorgente(client):
|
||||||
|
# nessun file: grab da camera:folder (la scacchiera in cartella)
|
||||||
|
r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["origine"] == "camera:folder"
|
||||||
|
assert salva_con_nome(client, r.json(), "Banco").status_code == 201 # #91: si salva con nome, dopo
|
||||||
|
|
||||||
|
# salvata ma non ancora caricata sulla sorgente
|
||||||
|
assert client.get("/camera/calibration", params={"source": "camera:folder"}).status_code == 404
|
||||||
|
assert carica_su(client, "camera:folder", "Banco").status_code == 200
|
||||||
|
|
||||||
|
r1 = client.get("/camera/calibration", params={"source": "camera:folder"})
|
||||||
|
assert r1.status_code == 200
|
||||||
|
assert set(r1.json()) == CHIAVI_PLANARE | CHIAVI_ATTIVA # nessun overlay nel file salvato
|
||||||
|
assert r1.json()["px_per_mm"] == pytest.approx(20.0, rel=0.005)
|
||||||
|
assert r1.json()["source"] == "camera:folder" and r1.json()["nome"] == "Banco"
|
||||||
|
file_salvati = list((client.config / "calibrazioni").glob("*.json"))
|
||||||
|
assert len(file_salvati) == 1
|
||||||
|
assert json.loads(file_salvati[0].read_text(encoding="utf-8"))["cols"] == 9
|
||||||
|
|
||||||
|
# DELETE /camera/calibration scollega: la libreria resta
|
||||||
|
assert client.delete("/camera/calibration", params={"source": "camera:folder"}).status_code == 204
|
||||||
|
assert client.get("/camera/calibration", params={"source": "camera:folder"}).status_code == 404
|
||||||
|
assert len(list((client.config / "calibrazioni").glob("*.json"))) == 1
|
||||||
|
# scollegare di nuovo e' idempotente
|
||||||
|
assert client.delete("/camera/calibration", params={"source": "camera:folder"}).status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
def test_c2_scacchiera_non_trovata_422(client):
|
||||||
|
bianca = np.full((300, 300), 255, np.uint8)
|
||||||
|
r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI},
|
||||||
|
files={"image": ("b.png", _png(bianca), "image/png")})
|
||||||
|
assert r.status_code == 422
|
||||||
|
assert "non trovata" in r.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_c2_parametri_invalidi_422(client):
|
||||||
|
r = client.post("/camera/calibrate",
|
||||||
|
data={"source": "camera:folder", "cols": "1", "rows": "6", "square_mm": "2"},
|
||||||
|
files={"image": ("s.png", _png(una_scacchiera()), "image/png")})
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_c2_immagine_illeggibile_422(client):
|
||||||
|
r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI},
|
||||||
|
files={"image": ("x.png", NON_IMMAGINE, "image/png")})
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_c2_errori_camera_come_grab(client):
|
||||||
|
r = client.post("/camera/calibrate", data={"source": "camera:non-esiste", **CAMPI})
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_due_nomi_simili_hanno_file_diversi(client):
|
||||||
|
# #91: i nomi si ripuliscono come prima (hash sul nome originale): due nomi diversi non si pestano
|
||||||
|
r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI},
|
||||||
|
files={"image": ("s.png", _png(una_scacchiera()), "image/png")})
|
||||||
|
assert r.status_code == 200
|
||||||
|
for nome in ("banco a b", "banco a_b"):
|
||||||
|
assert salva_con_nome(client, r.json(), nome).status_code == 201
|
||||||
|
assert len(list((client.config / "calibrazioni").glob("*.json"))) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_c4_pagina_html(client):
|
||||||
|
r = client.get("/calibrazione")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.headers["content-type"].startswith("text/html")
|
||||||
|
assert "Acquisisci e calibra" in r.text
|
||||||
|
assert "Lato dello scacco" in r.text and 'id="cols"' not in r.text # #84: un solo campo
|
||||||
|
assert "http://" not in r.text and "https://" not in r.text # offline, niente CDN
|
||||||
|
|
||||||
|
|
||||||
|
# ---- #84: calibrazione densa (logica pura)
|
||||||
|
|
||||||
|
def test_densa_trova_la_griglia_da_sola_e_la_scala():
|
||||||
|
_carica()
|
||||||
|
import calibrazione
|
||||||
|
r, mappa = calibrazione.calibra_scacchiera_densa(una_scacchiera_densa(k=1e-8), 2.0)
|
||||||
|
assert (r["cols"], r["rows"]) == (15, 11) # 640x480 con quadrati da 40 px
|
||||||
|
assert r["n_corners"] == 15 * 11
|
||||||
|
assert r["mm_per_px"] == pytest.approx(2.0 / 40, rel=0.005)
|
||||||
|
assert r["px_per_mm"] == pytest.approx(20.0, rel=0.005)
|
||||||
|
assert r["metodo"] == "densa" and r["distorsione"] is True
|
||||||
|
assert r["residuo_max_px"] < 0.3 and r["residuo_rms_px"] <= r["residuo_max_px"]
|
||||||
|
assert 60 < r["copertura_pct"] <= 100
|
||||||
|
assert (r["image_width"], r["image_height"]) == (640, 480)
|
||||||
|
assert base64.b64decode(r["overlay_png"])[:8] == b"\x89PNG\r\n\x1a\n"
|
||||||
|
assert mappa.shape == (480, 640) and mappa.pixel_size == pytest.approx(0.05, rel=0.005)
|
||||||
|
|
||||||
|
|
||||||
|
def test_densa_raddrizza_su_griglia_regolare():
|
||||||
|
_carica()
|
||||||
|
import calibrazione
|
||||||
|
img = una_scacchiera_densa(k=1e-7) # distorsione marcata: ai bordi ~5 %
|
||||||
|
r, mappa = calibrazione.calibra_scacchiera_densa(img, 2.0)
|
||||||
|
assert (r["cols"], r["rows"]) == (15, 11)
|
||||||
|
# prima la griglia e' curva, dopo e' un reticolo regolare
|
||||||
|
assert residuo_reticolo(calibrazione._trova_griglia(img)) > 1.0
|
||||||
|
pronta = calibrazione._prepara_mappa(mappa)
|
||||||
|
dritta = calibrazione.raddrizza(img, pronta)
|
||||||
|
assert dritta.shape == img.shape
|
||||||
|
assert residuo_reticolo(calibrazione._trova_griglia(dritta)) < 0.3
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("cosa", ["bianca", "scacchiera_piccola"])
|
||||||
|
def test_densa_senza_griglia_solleva(cosa):
|
||||||
|
_carica()
|
||||||
|
import calibrazione
|
||||||
|
img = (np.full((300, 300), 255, np.uint8) if cosa == "bianca"
|
||||||
|
else una_scacchiera_densa(80, 60, 20)) # 3x2 angoli: meno di 3x3
|
||||||
|
with pytest.raises(calibrazione.CalibrazioneError, match="non trovata"):
|
||||||
|
calibrazione.calibra_scacchiera_densa(img, 2.0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("quadrato", [0.0, -1.0, float("nan")])
|
||||||
|
def test_densa_quadrato_invalido_solleva(quadrato):
|
||||||
|
_carica()
|
||||||
|
import calibrazione
|
||||||
|
with pytest.raises(calibrazione.CalibrazioneError):
|
||||||
|
calibrazione.calibra_scacchiera_densa(una_scacchiera_densa(), quadrato)
|
||||||
|
|
||||||
|
|
||||||
|
def test_archivio_mappa_cache_e_invalidazione(tmp_path):
|
||||||
|
_carica()
|
||||||
|
import calibrazione
|
||||||
|
_, mappa = calibrazione.calibra_scacchiera_densa(una_scacchiera_densa(), 2.0)
|
||||||
|
arch = calibrazione.Archivio(tmp_path)
|
||||||
|
assert arch.leggi_mappa("s") is None
|
||||||
|
arch.salva_nome("cal", {"a": 1}, mappa) # #91: salvataggio con nome + attivazione sulla sorgente
|
||||||
|
arch.attiva("s", "cal")
|
||||||
|
prima = arch.leggi_mappa("s")
|
||||||
|
assert prima is not None and arch.leggi_mappa("s") is prima # cache in memoria
|
||||||
|
assert not list(tmp_path.rglob("*.tmp*")) # niente temporanei rimasti
|
||||||
|
arch.salva_nome("cal", {"a": 2}, mappa, sovrascrivi=True) # file riscritto -> cache invalidata
|
||||||
|
assert arch.leggi_mappa("s") is not prima
|
||||||
|
arch.salva_nome("cal", {"a": 3}, sovrascrivi=True) # salvataggio senza mappa (planare): la vecchia sparisce
|
||||||
|
assert arch.leggi_mappa("s") is None and not list(tmp_path.rglob("*.npz"))
|
||||||
|
|
||||||
|
|
||||||
|
# ---- #84: rotte con la calibrazione densa
|
||||||
|
|
||||||
|
def _grigio(r) -> np.ndarray:
|
||||||
|
return cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_GRAYSCALE)
|
||||||
|
|
||||||
|
|
||||||
|
def test_c2_senza_cols_rows_e_densa(client_denso):
|
||||||
|
r = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
corpo = r.json()
|
||||||
|
assert CHIAVI_DENSA | {"overlay_png", "calcolo_id"} == set(corpo)
|
||||||
|
assert corpo["metodo"] == "densa" and corpo["distorsione"] is True
|
||||||
|
assert (corpo["cols"], corpo["rows"]) == (15, 11)
|
||||||
|
assert corpo["px_per_mm"] == pytest.approx(1.0 / corpo["mm_per_px"])
|
||||||
|
assert not client_denso.config.exists() or not list(client_denso.config.iterdir())
|
||||||
|
|
||||||
|
|
||||||
|
def test_c2_solo_cols_o_solo_rows_422(client_denso):
|
||||||
|
r = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2", "cols": "9"})
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_grab_raddrizzato_se_calibrata_e_grezzo_a_richiesta(client_denso, cartella_densa):
|
||||||
|
originale = cv2.imread(str(cartella_densa / "densa.png"), cv2.IMREAD_GRAYSCALE)
|
||||||
|
prima = client_denso.post("/camera/grab", json={"source": SORGENTE})
|
||||||
|
assert prima.headers["x-raddrizzato"] == "no" # non calibrata: frame com'e'
|
||||||
|
assert np.array_equal(_grigio(prima), originale)
|
||||||
|
|
||||||
|
r = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert salva_con_nome(client_denso, r.json(), "Densa").status_code == 201 # #91: salvata con nome
|
||||||
|
libreria = client_denso.config / "calibrazioni"
|
||||||
|
json_salvati, mappe = list(libreria.glob("*.json")), list(libreria.glob("*.npz"))
|
||||||
|
assert len(json_salvati) == 1 and len(mappe) == 1
|
||||||
|
assert mappe[0].stem == json_salvati[0].stem # stesso nome del JSON
|
||||||
|
assert client_denso.post("/camera/grab", json={"source": SORGENTE}).headers["x-raddrizzato"] == "no" # salvata != attiva
|
||||||
|
assert carica_su(client_denso, SORGENTE, "Densa").status_code == 200
|
||||||
|
saved = client_denso.get("/camera/calibration", params={"source": SORGENTE}).json()
|
||||||
|
assert set(saved) == CHIAVI_DENSA | CHIAVI_ATTIVA and saved["metodo"] == "densa"
|
||||||
|
|
||||||
|
dopo = client_denso.post("/camera/grab", json={"source": SORGENTE})
|
||||||
|
assert dopo.status_code == 200 and dopo.headers["x-raddrizzato"] == "si"
|
||||||
|
import calibrazione
|
||||||
|
dritta = _grigio(dopo)
|
||||||
|
assert dritta.shape == originale.shape
|
||||||
|
assert not np.array_equal(dritta, originale)
|
||||||
|
assert residuo_reticolo(calibrazione._trova_griglia(dritta)) < 0.3 # griglia regolare
|
||||||
|
|
||||||
|
grezzo = client_denso.post("/camera/grab", json={"source": SORGENTE, "grezzo": True})
|
||||||
|
assert grezzo.headers["x-raddrizzato"] == "no"
|
||||||
|
assert np.array_equal(_grigio(grezzo), originale)
|
||||||
|
|
||||||
|
# la calibrazione parte sempre dal frame grezzo: ricalibrare dalla sorgente da' lo stesso mm/px
|
||||||
|
di_nuovo = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"})
|
||||||
|
assert di_nuovo.json()["mm_per_px"] == pytest.approx(r.json()["mm_per_px"])
|
||||||
|
assert di_nuovo.json()["residuo_max_px"] < 0.3
|
||||||
|
|
||||||
|
|
||||||
|
def test_grab_grezzo_non_booleano_400(client_denso):
|
||||||
|
r = client_denso.post("/camera/grab", json={"source": SORGENTE, "grezzo": "si"})
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_scollega_lascia_la_libreria_ed_elimina_toglie_la_mappa(client_denso):
|
||||||
|
r = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"})
|
||||||
|
salva_con_nome(client_denso, r.json(), "Densa")
|
||||||
|
carica_su(client_denso, SORGENTE, "Densa")
|
||||||
|
libreria = client_denso.config / "calibrazioni"
|
||||||
|
assert list(libreria.glob("*.npz"))
|
||||||
|
# scollegare: il frame torna com'e', ma la voce (e la mappa) restano in libreria
|
||||||
|
assert client_denso.delete("/camera/calibration", params={"source": SORGENTE}).status_code == 204
|
||||||
|
assert client_denso.post("/camera/grab", json={"source": SORGENTE}).headers["x-raddrizzato"] == "no"
|
||||||
|
assert list(libreria.glob("*.npz")) and list(libreria.glob("*.json"))
|
||||||
|
# eliminare la voce toglie JSON e mappa
|
||||||
|
carica_su(client_denso, SORGENTE, "Densa")
|
||||||
|
assert client_denso.delete("/camera/calibrazioni", params={"nome": "Densa"}).status_code == 204
|
||||||
|
assert not list(libreria.glob("*.npz")) and not list(libreria.glob("*.json"))
|
||||||
|
assert client_denso.post("/camera/grab", json={"source": SORGENTE}).headers["x-raddrizzato"] == "no"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ricalibrare_planare_toglie_la_mappa_densa(client_denso):
|
||||||
|
densa = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"})
|
||||||
|
salva_con_nome(client_denso, densa.json(), "Cal")
|
||||||
|
carica_su(client_denso, SORGENTE, "Cal")
|
||||||
|
libreria = client_denso.config / "calibrazioni"
|
||||||
|
assert list(libreria.glob("*.npz"))
|
||||||
|
planare = client_denso.post("/camera/calibrate", data={"source": SORGENTE, **CAMPI},
|
||||||
|
files={"image": ("s.png", _png(una_scacchiera()), "image/png")})
|
||||||
|
assert planare.status_code == 200 and planare.json()["metodo"] == "planare"
|
||||||
|
assert salva_con_nome(client_denso, planare.json(), "Cal").status_code == 409 # nome gia' preso
|
||||||
|
assert salva_con_nome(client_denso, planare.json(), "Cal", sovrascrivi=True).status_code == 201
|
||||||
|
assert not list(libreria.glob("*.npz")) # niente mappa vecchia che raddrizza a vuoto
|
||||||
|
assert client_denso.post("/camera/grab", json={"source": SORGENTE}).headers["x-raddrizzato"] == "no"
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
"""Test dell'archivio unico di calibrazioni con nome (#91): scripts/calibrazione.py, classe Archivio.
|
||||||
|
|
||||||
|
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
|
||||||
|
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
|
||||||
|
Solo tmp_path: niente ProgramData, niente camera.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from test_calibrazione import _carica, una_scacchiera_densa
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def mappa_densa():
|
||||||
|
# #91: una mappa vera, calcolata una volta sola per tutti i test del modulo
|
||||||
|
_carica()
|
||||||
|
import calibrazione
|
||||||
|
return calibrazione.calibra_scacchiera_densa(una_scacchiera_densa(), 2.0)[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _dati(**extra):
|
||||||
|
base = {"px_per_mm": 20.0, "mm_per_px": 0.05, "image_width": 640, "image_height": 480,
|
||||||
|
"metodo": "planare", "square_mm": 2.0, "calibrated_at": "2026-10-24T10:00:00+00:00",
|
||||||
|
"origine": "file:s.png"}
|
||||||
|
return {**base, **extra}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def cal():
|
||||||
|
_carica()
|
||||||
|
import calibrazione
|
||||||
|
return calibrazione
|
||||||
|
|
||||||
|
|
||||||
|
# ---- nomi
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("nome", ["", " ", "x" * 61, "a\x00b", "a\nb", "a\tb", None, 5])
|
||||||
|
def test_nome_non_valido(cal, tmp_path, nome):
|
||||||
|
with pytest.raises(cal.NomeNonValido):
|
||||||
|
cal.Archivio(tmp_path).salva_nome(nome, _dati())
|
||||||
|
|
||||||
|
|
||||||
|
def test_nome_valido_limiti_e_strip(cal, tmp_path):
|
||||||
|
arch = cal.Archivio(tmp_path)
|
||||||
|
arch.salva_nome("x" * 60, _dati())
|
||||||
|
meta = arch.salva_nome(" Banco 1 ", _dati())
|
||||||
|
assert meta["nome"] == "Banco 1" # strip
|
||||||
|
assert arch.leggi_nome("Banco 1")["nome"] == "Banco 1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_nome_unico_senza_maiuscole(cal, tmp_path):
|
||||||
|
arch = cal.Archivio(tmp_path)
|
||||||
|
arch.salva_nome("Banco", _dati(px_per_mm=1.0))
|
||||||
|
with pytest.raises(cal.NomeGiaUsato, match="Banco"):
|
||||||
|
arch.salva_nome("BANCO", _dati(px_per_mm=2.0))
|
||||||
|
assert arch.leggi_nome("banco")["px_per_mm"] == 1.0 # non toccata
|
||||||
|
assert len(arch.elenco()) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_sovrascrivi_sostituisce_e_prende_il_nuovo_nome(cal, tmp_path):
|
||||||
|
arch = cal.Archivio(tmp_path)
|
||||||
|
arch.salva_nome("Banco", _dati(px_per_mm=1.0))
|
||||||
|
arch.salva_nome("BANCO", _dati(px_per_mm=2.0), sovrascrivi=True)
|
||||||
|
voci = arch.elenco()
|
||||||
|
assert len(voci) == 1 and voci[0]["nome"] == "BANCO" and voci[0]["px_per_mm"] == 2.0
|
||||||
|
assert len(list((tmp_path / "calibrazioni").glob("*.json"))) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_salva_non_scrive_source_ne_overlay(cal, tmp_path):
|
||||||
|
arch = cal.Archivio(tmp_path)
|
||||||
|
arch.salva_nome("A", _dati(source="camera:x", overlay_png="abc", saved=True))
|
||||||
|
scritto = json.loads(next((tmp_path / "calibrazioni").glob("*.json")).read_text(encoding="utf-8"))
|
||||||
|
assert "source" not in scritto and "overlay_png" not in scritto and "saved" not in scritto
|
||||||
|
assert scritto["nome"] == "A" and scritto["origine"] == "file:s.png"
|
||||||
|
|
||||||
|
|
||||||
|
def test_leggi_nome_inesistente_o_non_valido(cal, tmp_path):
|
||||||
|
arch = cal.Archivio(tmp_path)
|
||||||
|
assert arch.leggi_nome("nessuna") is None and arch.leggi_nome("") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---- elenco e attive
|
||||||
|
|
||||||
|
def test_elenco_dalla_piu_recente_e_attiva_su(cal, tmp_path):
|
||||||
|
arch = cal.Archivio(tmp_path)
|
||||||
|
arch.salva_nome("vecchia", _dati(calibrated_at="2026-01-01T00:00:00+00:00"))
|
||||||
|
arch.salva_nome("nuova", _dati(calibrated_at="2026-10-01T00:00:00+00:00"))
|
||||||
|
arch.attiva("camera:b", "nuova")
|
||||||
|
arch.attiva("camera:a", "nuova")
|
||||||
|
voci = arch.elenco()
|
||||||
|
assert [v["nome"] for v in voci] == ["nuova", "vecchia"]
|
||||||
|
assert voci[0]["attiva_su"] == ["camera:a", "camera:b"] and voci[1]["attiva_su"] == []
|
||||||
|
assert set(voci[0]) == {"nome", "origine", "image_width", "image_height", "px_per_mm",
|
||||||
|
"mm_per_px", "metodo", "square_mm", "calibrated_at", "attiva_su"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_attiva_leggi_scollega(cal, tmp_path):
|
||||||
|
arch = cal.Archivio(tmp_path)
|
||||||
|
arch.salva_nome("Banco", _dati())
|
||||||
|
assert arch.attiva_di("camera:a") is None and arch.leggi("camera:a") is None
|
||||||
|
assert arch.attiva("camera:a", "banco") == "Banco" # nome come in libreria
|
||||||
|
assert arch.attiva_di("camera:a") == "Banco"
|
||||||
|
letta = arch.leggi("camera:a")
|
||||||
|
assert letta["nome"] == "Banco" and letta["source"] == "camera:a" and letta["px_per_mm"] == 20.0
|
||||||
|
assert json.loads((tmp_path / "attive.json").read_text(encoding="utf-8")) == {"camera:a": "Banco"}
|
||||||
|
arch.scollega("camera:a")
|
||||||
|
assert arch.attiva_di("camera:a") is None and arch.leggi("camera:a") is None
|
||||||
|
assert arch.leggi_nome("Banco") is not None # la libreria resta
|
||||||
|
arch.scollega("camera:a") # idempotente
|
||||||
|
|
||||||
|
|
||||||
|
def test_attiva_nome_sconosciuto(cal, tmp_path):
|
||||||
|
with pytest.raises(cal.NomeSconosciuto):
|
||||||
|
cal.Archivio(tmp_path).attiva("camera:a", "nessuna")
|
||||||
|
|
||||||
|
|
||||||
|
def test_due_sorgenti_stessa_calibrazione(cal, tmp_path):
|
||||||
|
arch = cal.Archivio(tmp_path)
|
||||||
|
arch.salva_nome("Comune", _dati())
|
||||||
|
arch.attiva("camera:a", "Comune")
|
||||||
|
arch.attiva("camera:b", "Comune")
|
||||||
|
assert arch.leggi("camera:a")["source"] == "camera:a" and arch.leggi("camera:b")["source"] == "camera:b"
|
||||||
|
|
||||||
|
|
||||||
|
def test_elimina_nome_toglie_da_attive_e_mappa(cal, tmp_path, mappa_densa):
|
||||||
|
arch = cal.Archivio(tmp_path)
|
||||||
|
arch.salva_nome("Densa", _dati(metodo="densa"), mappa_densa)
|
||||||
|
arch.salva_nome("Altra", _dati())
|
||||||
|
arch.attiva("camera:a", "Densa")
|
||||||
|
arch.attiva("camera:b", "Densa")
|
||||||
|
arch.attiva("camera:c", "Altra")
|
||||||
|
assert arch.leggi_mappa("camera:a") is not None
|
||||||
|
assert arch.elimina_nome("DENSA") is True
|
||||||
|
assert arch.leggi_nome("Densa") is None and not list((tmp_path / "calibrazioni").glob("*.npz"))
|
||||||
|
assert arch.attiva_di("camera:a") is None and arch.attiva_di("camera:b") is None
|
||||||
|
assert arch.leggi_mappa("camera:a") is None
|
||||||
|
assert arch.attiva_di("camera:c") == "Altra"
|
||||||
|
assert json.loads((tmp_path / "attive.json").read_text(encoding="utf-8")) == {"camera:c": "Altra"}
|
||||||
|
assert arch.elimina_nome("Densa") is False # gia' tolta
|
||||||
|
|
||||||
|
|
||||||
|
# ---- mappa
|
||||||
|
|
||||||
|
def test_leggi_mappa_risolve_l_attiva_con_cache(cal, tmp_path, mappa_densa):
|
||||||
|
arch = cal.Archivio(tmp_path)
|
||||||
|
assert arch.leggi_mappa("camera:a") is None
|
||||||
|
arch.salva_nome("Densa", _dati(metodo="densa"), mappa_densa)
|
||||||
|
assert arch.leggi_mappa("camera:a") is None # non attiva su camera:a
|
||||||
|
arch.attiva("camera:a", "Densa")
|
||||||
|
prima = arch.leggi_mappa("camera:a")
|
||||||
|
assert prima is not None and arch.leggi_mappa("camera:a") is prima # cache in memoria
|
||||||
|
assert arch.leggi_mappa("camera:b") is None
|
||||||
|
assert not list(tmp_path.rglob("*.tmp*")) # niente temporanei rimasti
|
||||||
|
arch.salva_nome("Densa", _dati(metodo="densa"), mappa_densa, sovrascrivi=True) # file riscritto
|
||||||
|
assert arch.leggi_mappa("camera:a") is not prima # cache invalidata
|
||||||
|
arch.salva_nome("Densa", _dati(), sovrascrivi=True) # sovrascritta con una planare: la mappa sparisce
|
||||||
|
assert arch.leggi_mappa("camera:a") is None and not list(tmp_path.rglob("*.npz"))
|
||||||
|
|
||||||
|
|
||||||
|
# ---- migrazione dal formato vecchio
|
||||||
|
|
||||||
|
def _vecchia(cartella, cal, source, mappa=None, **extra):
|
||||||
|
"""Scrive una calibrazione nel formato vecchio (un JSON per sorgente, + .npz) come faceva Archivio.salva."""
|
||||||
|
dati = {"source": source, **_dati(**extra)}
|
||||||
|
dati.pop("origine")
|
||||||
|
nudo = cal.Archivio.__new__(cal.Archivio) # senza costruttore: niente migrazione mentre si prepara
|
||||||
|
nudo.cartella = cartella
|
||||||
|
percorso = nudo._file(source)
|
||||||
|
cartella.mkdir(parents=True, exist_ok=True)
|
||||||
|
percorso.write_text(json.dumps(dati), encoding="utf-8")
|
||||||
|
if mappa is not None:
|
||||||
|
mappa.save(str(percorso.with_suffix(".npz")))
|
||||||
|
return percorso
|
||||||
|
|
||||||
|
|
||||||
|
def test_migrazione_formato_vecchio(cal, tmp_path, mappa_densa):
|
||||||
|
v1 = _vecchia(tmp_path, cal, "camera:601020", mappa_densa, metodo="densa", px_per_mm=7.0)
|
||||||
|
v2 = _vecchia(tmp_path, cal, "camera:folder", px_per_mm=9.0)
|
||||||
|
(tmp_path / "settaggi").mkdir()
|
||||||
|
(tmp_path / "settaggi" / v1.name).write_text("{}", encoding="utf-8") # non si tocca
|
||||||
|
arch = cal.Archivio(tmp_path)
|
||||||
|
assert not v1.exists() and not v1.with_suffix(".npz").exists() and not v2.exists()
|
||||||
|
assert {v["nome"] for v in arch.elenco()} == {"camera:601020", "camera:folder"}
|
||||||
|
assert arch.attiva_di("camera:601020") == "camera:601020" and arch.attiva_di("camera:folder") == "camera:folder"
|
||||||
|
letta = arch.leggi("camera:601020")
|
||||||
|
assert letta["px_per_mm"] == 7.0 and letta["source"] == "camera:601020"
|
||||||
|
assert arch.leggi_mappa("camera:601020") is not None # il .npz ha seguito il JSON
|
||||||
|
assert len(list((tmp_path / "calibrazioni").glob("*.npz"))) == 1
|
||||||
|
assert (tmp_path / "settaggi" / v1.name).read_text(encoding="utf-8") == "{}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_migrazione_idempotente(cal, tmp_path):
|
||||||
|
_vecchia(tmp_path, cal, "camera:a")
|
||||||
|
cal.Archivio(tmp_path)
|
||||||
|
prima = sorted(p.name for p in tmp_path.rglob("*") if p.is_file())
|
||||||
|
arch = cal.Archivio(tmp_path) # secondo avvio
|
||||||
|
arch.migra()
|
||||||
|
assert sorted(p.name for p in tmp_path.rglob("*") if p.is_file()) == prima
|
||||||
|
assert [v["nome"] for v in arch.elenco()] == ["camera:a"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_migrazione_nome_gia_preso(cal, tmp_path):
|
||||||
|
arch = cal.Archivio(tmp_path)
|
||||||
|
arch.salva_nome("camera:a", _dati(px_per_mm=1.0, calibrated_at="2020-01-01T00:00:00+00:00"))
|
||||||
|
_vecchia(tmp_path, cal, "camera:a", px_per_mm=5.0)
|
||||||
|
arch.migra()
|
||||||
|
nomi = {v["nome"]: v["px_per_mm"] for v in arch.elenco()}
|
||||||
|
assert nomi == {"camera:a": 1.0, "camera:a (2)": 5.0}
|
||||||
|
assert arch.attiva_di("camera:a") == "camera:a (2)" # la sorgente usa la sua, non quella gia' presente
|
||||||
|
# un altro file con lo stesso nome -> (3)
|
||||||
|
_vecchia(tmp_path, cal, "camera:A", px_per_mm=6.0) # nome uguale senza maiuscole
|
||||||
|
arch.migra()
|
||||||
|
assert "camera:A (3)" in {v["nome"] for v in arch.elenco()}
|
||||||
|
|
||||||
|
|
||||||
|
def test_migrazione_ignora_json_senza_source_e_attive(cal, tmp_path):
|
||||||
|
tmp_path.mkdir(exist_ok=True)
|
||||||
|
(tmp_path / "altro.json").write_text(json.dumps({"x": 1}), encoding="utf-8")
|
||||||
|
(tmp_path / "rotto.json").write_text("{non json", encoding="utf-8")
|
||||||
|
arch = cal.Archivio(tmp_path)
|
||||||
|
arch.salva_nome("A", _dati())
|
||||||
|
arch.attiva("source", "A") # una sorgente chiamata «source» non deve far scambiare attive.json per un vecchio file
|
||||||
|
arch.migra()
|
||||||
|
assert (tmp_path / "altro.json").exists() and (tmp_path / "rotto.json").exists()
|
||||||
|
assert arch.attiva_di("source") == "A" and [v["nome"] for v in arch.elenco()] == ["A"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_cartella_assente_non_da_errore(cal, tmp_path):
|
||||||
|
arch = cal.Archivio(tmp_path / "non" / "esiste")
|
||||||
|
assert arch.elenco() == [] and arch.leggi("s") is None and arch.leggi_mappa("s") is None
|
||||||
|
arch.scollega("s")
|
||||||
|
assert arch.elimina_nome("x") is False
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
"""Test delle rotte dell'archivio unico di calibrazioni con nome (#91): scripts/camera_service.py.
|
||||||
|
|
||||||
|
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
|
||||||
|
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
|
||||||
|
Sorgenti: camera:folder su cartelle temporanee (520x400 scacchiera piana, 640x480 densa).
|
||||||
|
Solo tmp_path: niente ProgramData, niente camera vera.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from test_calibrazione import ( # fixture e helper della calibrazione (#83/#84)
|
||||||
|
CAMPI, CHIAVI_ATTIVA, CHIAVI_DENSA, CHIAVI_PLANARE, SORGENTE, _carica, _png,
|
||||||
|
carica_su, cartella, cartella_densa, client, client_denso, salva_con_nome,
|
||||||
|
una_scacchiera, una_scacchiera_densa,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = ["cartella", "cartella_densa", "client", "client_denso"] # le fixture importate restano usate
|
||||||
|
|
||||||
|
|
||||||
|
def _calcola_file(c, **extra):
|
||||||
|
"""Calcolo planare da un'immagine (520x400): ritorna la risposta JSON."""
|
||||||
|
r = c.post("/camera/calibrate", data={"source": SORGENTE, **CAMPI, **extra},
|
||||||
|
files={"image": ("scacco.png", _png(una_scacchiera()), "image/png")})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
# ---- POST /camera/calibrate
|
||||||
|
|
||||||
|
def test_calibrate_senza_source_con_immagine_ok(client):
|
||||||
|
r = client.post("/camera/calibrate", data=CAMPI,
|
||||||
|
files={"image": ("C:\\foto\\scacco.png", _png(una_scacchiera()), "image/png")})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["origine"] == "file:scacco.png" # solo il nome del file, non il percorso
|
||||||
|
|
||||||
|
|
||||||
|
def test_calibrate_senza_source_e_senza_immagine_400(client):
|
||||||
|
r = client.post("/camera/calibrate", data=CAMPI)
|
||||||
|
assert r.status_code == 400 and "source" in r.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_calibrate_da_sorgente_ha_origine_camera_e_id(client):
|
||||||
|
r = client.post("/camera/calibrate", data={"source": SORGENTE, **CAMPI})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
corpo = r.json()
|
||||||
|
assert corpo["origine"] == SORGENTE and corpo["calibrated_at"]
|
||||||
|
assert len(corpo["calcolo_id"]) == 32 and int(corpo["calcolo_id"], 16) >= 0 # esadecimale
|
||||||
|
assert corpo["calcolo_id"] != client.post("/camera/calibrate", data={"source": SORGENTE, **CAMPI}).json()["calcolo_id"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---- POST /camera/calibrazioni
|
||||||
|
|
||||||
|
def test_salva_201_con_metadati(client):
|
||||||
|
calcolo = _calcola_file(client)
|
||||||
|
r = salva_con_nome(client, calcolo, " Banco 1 ")
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
meta = r.json()
|
||||||
|
assert meta["nome"] == "Banco 1" and meta["origine"] == "file:scacco.png"
|
||||||
|
assert (meta["image_width"], meta["image_height"]) == (520, 400)
|
||||||
|
assert meta["metodo"] == "planare" and meta["attiva_su"] == []
|
||||||
|
assert meta["px_per_mm"] == pytest.approx(20.0, rel=0.005)
|
||||||
|
# lo stesso calcolo si puo' salvare anche con un altro nome
|
||||||
|
assert salva_con_nome(client, calcolo, "Banco 2").status_code == 201
|
||||||
|
|
||||||
|
|
||||||
|
def test_salva_calcolo_sconosciuto_404(client):
|
||||||
|
r = client.post("/camera/calibrazioni", json={"calcolo_id": "0" * 32, "nome": "X"})
|
||||||
|
assert r.status_code == 404 and r.json()["detail"] == "calcolo non più disponibile: ricalcolare"
|
||||||
|
|
||||||
|
|
||||||
|
def test_salva_calcolo_scaduto_dopo_otto_calcoli_404(client):
|
||||||
|
primo = _calcola_file(client)
|
||||||
|
ultimo = primo
|
||||||
|
for _ in range(8): # il nono calcolo spinge fuori il primo
|
||||||
|
ultimo = _calcola_file(client)
|
||||||
|
assert salva_con_nome(client, primo, "Vecchio").status_code == 404
|
||||||
|
assert salva_con_nome(client, ultimo, "Nuovo").status_code == 201
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("nome", ["", " ", "x" * 61, "a\x01b", None, 7])
|
||||||
|
def test_salva_nome_non_valido_400(client, nome):
|
||||||
|
r = client.post("/camera/calibrazioni", json={"calcolo_id": _calcola_file(client)["calcolo_id"], "nome": nome})
|
||||||
|
assert r.status_code == 400, r.text
|
||||||
|
assert client.get("/camera/calibrazioni").json()["calibrazioni"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_salva_corpo_incompleto_400(client):
|
||||||
|
assert client.post("/camera/calibrazioni", json={"nome": "X"}).status_code == 400
|
||||||
|
calcolo = _calcola_file(client)
|
||||||
|
r = client.post("/camera/calibrazioni", json={"calcolo_id": calcolo["calcolo_id"], "nome": "X", "sovrascrivi": "si"})
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_salva_nome_gia_usato_409_e_sovrascrivi(client):
|
||||||
|
a = _calcola_file(client)
|
||||||
|
assert salva_con_nome(client, a, "Banco").status_code == 201
|
||||||
|
r = salva_con_nome(client, a, "BANCO") # senza distinguere maiuscole
|
||||||
|
assert r.status_code == 409 and r.json()["detail"] == "esiste già una calibrazione «Banco»"
|
||||||
|
assert len(client.get("/camera/calibrazioni").json()["calibrazioni"]) == 1
|
||||||
|
r = salva_con_nome(client, a, "BANCO", sovrascrivi=True)
|
||||||
|
assert r.status_code == 201 and r.json()["nome"] == "BANCO"
|
||||||
|
voci = client.get("/camera/calibrazioni").json()["calibrazioni"]
|
||||||
|
assert [v["nome"] for v in voci] == ["BANCO"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---- GET /camera/calibrazioni
|
||||||
|
|
||||||
|
def test_elenco_vuoto_e_ordine(client):
|
||||||
|
assert client.get("/camera/calibrazioni").json() == {"calibrazioni": [], "attiva": None}
|
||||||
|
prima = _calcola_file(client)
|
||||||
|
salva_con_nome(client, prima, "prima")
|
||||||
|
seconda = _calcola_file(client)
|
||||||
|
seconda_ts = seconda["calibrated_at"]
|
||||||
|
salva_con_nome(client, seconda, "seconda")
|
||||||
|
voci = client.get("/camera/calibrazioni").json()["calibrazioni"]
|
||||||
|
assert {v["nome"] for v in voci} == {"prima", "seconda"}
|
||||||
|
assert [v["calibrated_at"] for v in voci] == sorted((v["calibrated_at"] for v in voci), reverse=True)
|
||||||
|
assert set(voci[0]) == {"nome", "origine", "image_width", "image_height", "px_per_mm", "mm_per_px",
|
||||||
|
"metodo", "square_mm", "calibrated_at", "attiva_su"}
|
||||||
|
assert seconda_ts >= prima["calibrated_at"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_elenco_con_source_dice_l_attiva(client):
|
||||||
|
salva_con_nome(client, _calcola_file(client), "Banco")
|
||||||
|
salva_con_nome(client, _calcola_file(client), "Altra")
|
||||||
|
assert carica_su(client, SORGENTE, "banco").status_code == 200
|
||||||
|
r = client.get("/camera/calibrazioni", params={"source": SORGENTE}).json()
|
||||||
|
assert r["attiva"] == "Banco"
|
||||||
|
attiva_su = {v["nome"]: v["attiva_su"] for v in r["calibrazioni"]}
|
||||||
|
assert attiva_su == {"Banco": [SORGENTE], "Altra": []}
|
||||||
|
assert client.get("/camera/calibrazioni", params={"source": "camera:altra"}).json()["attiva"] is None
|
||||||
|
assert client.get("/camera/calibrazioni", params={"source": " "}).status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# ---- DELETE /camera/calibrazioni
|
||||||
|
|
||||||
|
def test_elimina_204_404_e_toglie_dalle_sorgenti(client):
|
||||||
|
salva_con_nome(client, _calcola_file(client), "Banco")
|
||||||
|
carica_su(client, SORGENTE, "Banco")
|
||||||
|
assert client.delete("/camera/calibrazioni", params={"nome": "BANCO"}).status_code == 204
|
||||||
|
assert client.get("/camera/calibrazioni").json()["calibrazioni"] == []
|
||||||
|
assert client.get("/camera/calibration", params={"source": SORGENTE}).status_code == 404 # non piu' attiva
|
||||||
|
assert json.loads((client.config / "attive.json").read_text(encoding="utf-8")) == {}
|
||||||
|
assert client.delete("/camera/calibrazioni", params={"nome": "Banco"}).status_code == 404
|
||||||
|
assert client.delete("/camera/calibrazioni").status_code == 404 # senza nome
|
||||||
|
|
||||||
|
|
||||||
|
# ---- PUT /camera/calibrazione-attiva
|
||||||
|
|
||||||
|
def test_carica_risoluzione_uguale_200_come_get(client):
|
||||||
|
salva_con_nome(client, _calcola_file(client), "Banco") # 520x400 = la scacchiera in cartella
|
||||||
|
r = carica_su(client, SORGENTE, "banco")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert set(r.json()) == CHIAVI_PLANARE | CHIAVI_ATTIVA
|
||||||
|
assert r.json()["nome"] == "Banco" and r.json()["source"] == SORGENTE
|
||||||
|
assert client.get("/camera/calibration", params={"source": SORGENTE}).json() == r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_carica_risoluzione_diversa_409_e_anteprima_continua(client_denso):
|
||||||
|
# calibrazione da file 520x400, sorgente 640x480
|
||||||
|
salva_con_nome(client_denso, _calcola_file(client_denso), "Piccola")
|
||||||
|
r = carica_su(client_denso, SORGENTE, "Piccola")
|
||||||
|
assert r.status_code == 409
|
||||||
|
assert r.json()["detail"] == ("la calibrazione «Piccola» è per immagini 520×400, "
|
||||||
|
"la sorgente dà 640×480: non si può caricare")
|
||||||
|
assert client_denso.get("/camera/calibration", params={"source": SORGENTE}).status_code == 404 # nulla attivato
|
||||||
|
# l'acquisizione continua: grab e anteprima rispondono, frame non raddrizzato
|
||||||
|
g = client_denso.post("/camera/grab", json={"source": SORGENTE})
|
||||||
|
assert g.status_code == 200 and g.headers["x-raddrizzato"] == "no"
|
||||||
|
a = client_denso.post("/camera/anteprima", json={"source": SORGENTE})
|
||||||
|
assert a.status_code == 200 and a.headers["x-raddrizzato"] == "no"
|
||||||
|
assert (a.headers["x-larghezza"], a.headers["x-altezza"]) == ("640", "480")
|
||||||
|
|
||||||
|
|
||||||
|
def test_carica_risoluzione_diversa_non_scalza_l_attiva_precedente(client_denso):
|
||||||
|
densa = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"}).json()
|
||||||
|
salva_con_nome(client_denso, densa, "Giusta")
|
||||||
|
salva_con_nome(client_denso, _calcola_file(client_denso), "Sbagliata")
|
||||||
|
assert carica_su(client_denso, SORGENTE, "Giusta").status_code == 200
|
||||||
|
assert carica_su(client_denso, SORGENTE, "Sbagliata").status_code == 409
|
||||||
|
assert client_denso.get("/camera/calibration", params={"source": SORGENTE}).json()["nome"] == "Giusta"
|
||||||
|
assert client_denso.post("/camera/grab", json={"source": SORGENTE}).headers["x-raddrizzato"] == "si"
|
||||||
|
|
||||||
|
|
||||||
|
def test_carica_densa_su_sorgente_stessa_risoluzione_raddrizza(client_denso):
|
||||||
|
densa = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"}).json()
|
||||||
|
assert salva_con_nome(client_denso, densa, "Densa").json()["metodo"] == "densa"
|
||||||
|
r = carica_su(client_denso, SORGENTE, "Densa")
|
||||||
|
assert r.status_code == 200 and set(r.json()) == CHIAVI_DENSA | CHIAVI_ATTIVA
|
||||||
|
assert client_denso.post("/camera/anteprima", json={"source": SORGENTE}).headers["x-raddrizzato"] == "si"
|
||||||
|
|
||||||
|
|
||||||
|
def test_carica_nome_sconosciuto_404(client):
|
||||||
|
r = carica_su(client, SORGENTE, "nessuna")
|
||||||
|
assert r.status_code == 404
|
||||||
|
assert client.put("/camera/calibrazione-attiva", json={"source": SORGENTE}).status_code == 404 # senza nome
|
||||||
|
assert client.put("/camera/calibrazione-attiva", json={"nome": "x"}).status_code == 400 # senza source
|
||||||
|
|
||||||
|
|
||||||
|
def test_carica_su_sorgente_inesistente_404(client):
|
||||||
|
salva_con_nome(client, _calcola_file(client), "Banco")
|
||||||
|
assert carica_su(client, "camera:non-esiste", "Banco").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_carica_senza_risoluzione_registrata_409(cartella, tmp_path):
|
||||||
|
_carica()
|
||||||
|
import calibrazione
|
||||||
|
config = tmp_path / "config_senza"
|
||||||
|
calibrazione.Archivio(config).salva_nome("Senza", {"px_per_mm": 10.0, "calibrated_at": "2026-01-01T00:00:00+00:00"})
|
||||||
|
with TestClient(_carica().create_app(cartella, config_dir=config)) as c:
|
||||||
|
r = carica_su(c, SORGENTE, "Senza")
|
||||||
|
assert r.status_code == 409
|
||||||
|
assert r.json()["detail"] == "la calibrazione «Senza» non ha la risoluzione registrata: non si può caricare"
|
||||||
|
|
||||||
|
|
||||||
|
# ---- contratto verso il backend e migrazione all'avvio
|
||||||
|
|
||||||
|
def test_get_calibration_contratto_per_il_backend(client):
|
||||||
|
# src/backend/services/vision_service.py legge questi campi
|
||||||
|
salva_con_nome(client, _calcola_file(client), "Banco")
|
||||||
|
carica_su(client, SORGENTE, "Banco")
|
||||||
|
dati = client.get("/camera/calibration", params={"source": SORGENTE}).json()
|
||||||
|
for campo in ("px_per_mm", "mm_per_px", "image_width", "image_height", "calibrated_at", "metodo"):
|
||||||
|
assert campo in dati
|
||||||
|
assert client.get("/camera/calibration", params={"source": "camera:altra"}).json() == {
|
||||||
|
"detail": "sorgente non calibrata"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_avvio_migra_il_formato_vecchio(cartella, tmp_path):
|
||||||
|
_carica()
|
||||||
|
import calibrazione
|
||||||
|
config = tmp_path / "config_vecchia"
|
||||||
|
config.mkdir()
|
||||||
|
nudo = calibrazione.Archivio.__new__(calibrazione.Archivio) # per avere il nome file del formato vecchio
|
||||||
|
nudo.cartella = config
|
||||||
|
vecchio = nudo._file(SORGENTE)
|
||||||
|
vecchio.write_text(json.dumps({"source": SORGENTE, "px_per_mm": 20.0, "mm_per_px": 0.05,
|
||||||
|
"image_width": 520, "image_height": 400, "metodo": "planare",
|
||||||
|
"calibrated_at": "2026-10-01T10:00:00+00:00"}), encoding="utf-8")
|
||||||
|
with TestClient(_carica().create_app(cartella, config_dir=config)) as c:
|
||||||
|
dati = c.get("/camera/calibration", params={"source": SORGENTE}).json()
|
||||||
|
assert dati["nome"] == SORGENTE and dati["source"] == SORGENTE and dati["px_per_mm"] == 20.0
|
||||||
|
voci = c.get("/camera/calibrazioni", params={"source": SORGENTE}).json()
|
||||||
|
assert [v["nome"] for v in voci["calibrazioni"]] == [SORGENTE] and voci["attiva"] == SORGENTE
|
||||||
|
assert voci["calibrazioni"][0]["attiva_su"] == [SORGENTE]
|
||||||
|
assert not vecchio.exists()
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
"""Test delle rotte di acquisizione di scripts/camera_service.py (#87).
|
||||||
|
|
||||||
|
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
|
||||||
|
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
|
||||||
|
Copre: POST /camera/anteprima, GET/PUT /camera/parametri, POST /camera/parametri/salva,
|
||||||
|
caricamento dei settaggi alla riapertura, alias GET /acquisizione.
|
||||||
|
Sorgente regolabile finta (iniettata su open_source) e FolderCamera su PNG temporanei.
|
||||||
|
"""
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from visionsuite.camera.camera_interface import CameraStateError
|
||||||
|
|
||||||
|
MODULO = Path(__file__).resolve().parents[1] / "camera_service.py"
|
||||||
|
SORGENTE = "camera:finta"
|
||||||
|
|
||||||
|
|
||||||
|
def _carica():
|
||||||
|
spec = importlib.util.spec_from_file_location("camera_service_acq", MODULO)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
class Finta:
|
||||||
|
"""Camera regolabile finta: stesso vocabolario di GenicamSource."""
|
||||||
|
|
||||||
|
DOMINI = {
|
||||||
|
"esposizione": {"tipo": "numero", "min": 10.0, "max": 100000.0, "passo": 1.0},
|
||||||
|
"esposizione_auto": {"tipo": "scelta", "scelte": ["Off", "Once", "Continuous"]},
|
||||||
|
"guadagno": {"tipo": "numero", "min": 0.0, "max": 24.0},
|
||||||
|
"guadagno_auto": {"tipo": "scelta", "scelte": ["Off", "Once", "Continuous"]},
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, supportati=None, rifiuta=()):
|
||||||
|
self.valori = {"esposizione": 5000.0, "esposizione_auto": "Continuous",
|
||||||
|
"guadagno": 1.0, "guadagno_auto": "Off"}
|
||||||
|
self.supportati = set(supportati if supportati is not None else self.DOMINI)
|
||||||
|
self.rifiuta = set(rifiuta)
|
||||||
|
self.scritture = []
|
||||||
|
self.caricato = None
|
||||||
|
self.chiusa = False
|
||||||
|
# metà bianca (255) e metà a 100: saturati 50%, media 177,5
|
||||||
|
frame = np.full((1000, 2000, 3), 100, np.uint8)
|
||||||
|
frame[:, :1000] = 255
|
||||||
|
self.frame = frame
|
||||||
|
|
||||||
|
def supporta(self, nome):
|
||||||
|
return nome in self.supportati
|
||||||
|
|
||||||
|
def dominio(self, nome):
|
||||||
|
return {"capacita": nome, "valore": self.valori[nome], **self.DOMINI[nome]}
|
||||||
|
|
||||||
|
def leggi(self, nome):
|
||||||
|
return self.valori[nome]
|
||||||
|
|
||||||
|
def scrivi(self, nome, valore, assesta=0):
|
||||||
|
if nome in self.rifiuta:
|
||||||
|
raise CameraStateError(f"non posso impostare {nome}: nodo in sola lettura")
|
||||||
|
self.scritture.append((nome, valore, assesta))
|
||||||
|
self.valori[nome] = valore
|
||||||
|
|
||||||
|
def salva_settaggi(self, percorso):
|
||||||
|
Path(percorso).write_text(json.dumps(self.valori), encoding="utf-8")
|
||||||
|
return dict(self.valori)
|
||||||
|
|
||||||
|
def carica_settaggi(self, percorso, forza=False):
|
||||||
|
self.caricato = Path(percorso)
|
||||||
|
self.valori.update(json.loads(self.caricato.read_text(encoding="utf-8")))
|
||||||
|
return []
|
||||||
|
|
||||||
|
def grab(self, timeout=0):
|
||||||
|
return self.frame.copy()
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.chiusa = True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def cartella(tmp_path):
|
||||||
|
img = tmp_path / "foto"
|
||||||
|
img.mkdir()
|
||||||
|
for nome in ("a.png", "b.png"):
|
||||||
|
cv2.imwrite(str(img / nome), np.full((48, 64, 3), 128, np.uint8))
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def _client(cartella, config, monkeypatch, finta):
|
||||||
|
mod = _carica()
|
||||||
|
monkeypatch.setattr(mod, "open_source", lambda *a, **k: finta)
|
||||||
|
c = TestClient(mod.create_app(cartella, config))
|
||||||
|
c.mod = mod
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def finta():
|
||||||
|
return Finta()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(cartella, tmp_path, monkeypatch, finta):
|
||||||
|
with _client(cartella, tmp_path / "cfg", monkeypatch, finta) as c:
|
||||||
|
c.finta = finta
|
||||||
|
c.config = tmp_path / "cfg"
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client_cartella(cartella, tmp_path):
|
||||||
|
mod = _carica()
|
||||||
|
with TestClient(mod.create_app(cartella, tmp_path / "cfg")) as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
# ---- anteprima
|
||||||
|
|
||||||
|
def test_anteprima_jpeg_ridotta_e_header(client):
|
||||||
|
r = client.post("/camera/anteprima", json={"source": SORGENTE, "lato_max": 500})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.headers["content-type"] == "image/jpeg"
|
||||||
|
img = cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_COLOR)
|
||||||
|
assert img.shape[:2] == (250, 500) # lato lungo 2000 -> 500
|
||||||
|
assert r.headers["x-raddrizzato"] == "no"
|
||||||
|
assert (r.headers["x-larghezza"], r.headers["x-altezza"]) == ("2000", "1000") # frame pieno
|
||||||
|
assert r.headers["x-saturati-pct"] == "50.00"
|
||||||
|
assert r.headers["x-media"] == "177.5"
|
||||||
|
assert float(r.headers["x-nitidezza"]) > 0 # bordo netto fra le due metà
|
||||||
|
|
||||||
|
|
||||||
|
def test_anteprima_mai_ingrandita(client_cartella):
|
||||||
|
r = client_cartella.post("/camera/anteprima", json={"source": "camera:folder"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
img = cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_COLOR)
|
||||||
|
assert img.shape[:2] == (48, 64) # lato_max 1280 > 64: resta com'è
|
||||||
|
assert r.headers["x-nitidezza"] == "0.0" # grigio uniforme
|
||||||
|
assert r.headers["x-saturati-pct"] == "0.00"
|
||||||
|
assert r.headers["x-media"] == "128.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_anteprima_parametri_errati_400(client):
|
||||||
|
for corpo in ({}, {"source": SORGENTE, "lato_max": 100}, {"source": SORGENTE, "lato_max": 5000},
|
||||||
|
{"source": SORGENTE, "timeout_ms": 0}, {"source": SORGENTE, "grezzo": "si"}):
|
||||||
|
assert client.post("/camera/anteprima", json=corpo).status_code == 400, corpo
|
||||||
|
|
||||||
|
|
||||||
|
def test_anteprima_sorgente_inesistente_404(client_cartella):
|
||||||
|
assert client_cartella.post("/camera/anteprima",
|
||||||
|
json={"source": "camera:non-esiste"}).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_anteprima_errore_camera_503_e_timeout_504(client):
|
||||||
|
from visionsuite.camera.camera_interface import CameraGrabError
|
||||||
|
|
||||||
|
def rotta(timeout=0):
|
||||||
|
raise CameraGrabError("Camera is not open")
|
||||||
|
|
||||||
|
client.finta.grab = rotta
|
||||||
|
r = client.post("/camera/anteprima", json={"source": SORGENTE})
|
||||||
|
assert r.status_code == 503 and r.json()["detail"] == "La camera non è aperta"
|
||||||
|
|
||||||
|
def lenta(timeout=0):
|
||||||
|
raise TimeoutError("scaduto")
|
||||||
|
|
||||||
|
client.finta.grab = lenta
|
||||||
|
assert client.post("/camera/anteprima", json={"source": SORGENTE}).status_code == 504
|
||||||
|
|
||||||
|
|
||||||
|
# ---- parametri
|
||||||
|
|
||||||
|
def test_parametri_regolabile(client):
|
||||||
|
r = client.get("/camera/parametri", params={"source": SORGENTE})
|
||||||
|
assert r.status_code == 200
|
||||||
|
j = r.json()
|
||||||
|
assert j["source"] == SORGENTE and j["regolabile"] is True
|
||||||
|
assert list(j["parametri"]) == ["esposizione", "esposizione_auto", "guadagno", "guadagno_auto"]
|
||||||
|
assert j["parametri"]["esposizione"]["valore"] == 5000.0
|
||||||
|
assert j["parametri"]["esposizione_auto"]["scelte"] == ["Off", "Once", "Continuous"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parametri_solo_quelli_supportati(cartella, tmp_path, monkeypatch):
|
||||||
|
with _client(cartella, tmp_path / "cfg", monkeypatch, Finta(supportati=["esposizione"])) as c:
|
||||||
|
j = c.get("/camera/parametri", params={"source": SORGENTE}).json()
|
||||||
|
assert j["regolabile"] is True and list(j["parametri"]) == ["esposizione"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parametri_sorgente_non_regolabile(client_cartella):
|
||||||
|
j = client_cartella.get("/camera/parametri", params={"source": "camera:folder"}).json()
|
||||||
|
assert j == {"source": "camera:folder", "regolabile": False, "parametri": {}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_parametri_senza_source_400(client):
|
||||||
|
assert client.get("/camera/parametri").status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_scrive_prima_gli_auto(client):
|
||||||
|
r = client.put("/camera/parametri", json={"source": SORGENTE, "valori": {
|
||||||
|
"esposizione": 8000, "guadagno": 3.5, "esposizione_auto": "Off", "guadagno_auto": "Off"}})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert client.finta.scritture == [
|
||||||
|
("esposizione_auto", "Off", 3), ("guadagno_auto", "Off", 3),
|
||||||
|
("esposizione", 8000, 3), ("guadagno", 3.5, 3)]
|
||||||
|
j = r.json() # valori riletti dalla camera
|
||||||
|
assert j["parametri"]["esposizione"]["valore"] == 8000
|
||||||
|
assert j["parametri"]["esposizione_auto"]["valore"] == "Off"
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_nome_ignoto_400(client):
|
||||||
|
r = client.put("/camera/parametri", json={"source": SORGENTE, "valori": {"frame_rate": 10}})
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert client.finta.scritture == []
|
||||||
|
for corpo in ({"source": SORGENTE}, {"source": SORGENTE, "valori": {}},
|
||||||
|
{"source": SORGENTE, "valori": {"guadagno": True}}):
|
||||||
|
assert client.put("/camera/parametri", json=corpo).status_code == 400, corpo
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_non_regolabile_409(client_cartella):
|
||||||
|
r = client_cartella.put("/camera/parametri",
|
||||||
|
json={"source": "camera:folder", "valori": {"esposizione": 1000}})
|
||||||
|
assert r.status_code == 409
|
||||||
|
assert r.json()["detail"] == "questa sorgente non ha parametri regolabili"
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_rifiuto_della_camera_422(cartella, tmp_path, monkeypatch):
|
||||||
|
with _client(cartella, tmp_path / "cfg", monkeypatch, Finta(rifiuta=["guadagno"])) as c:
|
||||||
|
r = c.put("/camera/parametri", json={"source": SORGENTE, "valori": {"guadagno": 5}})
|
||||||
|
assert r.status_code == 422
|
||||||
|
assert "guadagno" in r.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---- salvataggio e riapertura
|
||||||
|
|
||||||
|
def test_salva_crea_il_file(client):
|
||||||
|
r = client.post("/camera/parametri/salva", json={"source": SORGENTE})
|
||||||
|
assert r.status_code == 200
|
||||||
|
j = r.json()
|
||||||
|
assert j["source"] == SORGENTE and j["salvati"] is True
|
||||||
|
atteso = client.config / "settaggi" / j["file"]
|
||||||
|
assert atteso.is_file()
|
||||||
|
assert j["file"].startswith("camera_finta-") and j["file"].endswith(".json")
|
||||||
|
assert json.loads(atteso.read_text(encoding="utf-8"))["esposizione"] == 5000.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_salva_non_regolabile_409(client_cartella):
|
||||||
|
r = client_cartella.post("/camera/parametri/salva", json={"source": "camera:folder"})
|
||||||
|
assert r.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_riapertura_carica_i_settaggi(cartella, tmp_path, monkeypatch):
|
||||||
|
config = tmp_path / "cfg"
|
||||||
|
prima = Finta()
|
||||||
|
with _client(cartella, config, monkeypatch, prima) as c:
|
||||||
|
c.put("/camera/parametri", json={"source": SORGENTE, "valori": {"esposizione": 7777}})
|
||||||
|
file = c.post("/camera/parametri/salva", json={"source": SORGENTE}).json()["file"]
|
||||||
|
seconda = Finta() # servizio riavviato: camera nuova, valori di fabbrica
|
||||||
|
with _client(cartella, config, monkeypatch, seconda) as c:
|
||||||
|
assert seconda.caricato is None # niente apertura finché nessuno la chiede
|
||||||
|
c.get("/camera/parametri", params={"source": SORGENTE})
|
||||||
|
assert seconda.caricato == config / "settaggi" / file
|
||||||
|
assert seconda.valori["esposizione"] == 7777
|
||||||
|
|
||||||
|
|
||||||
|
def test_settaggi_rotti_non_bloccano_l_apertura(cartella, tmp_path, monkeypatch):
|
||||||
|
class Rotta(Finta):
|
||||||
|
def carica_settaggi(self, percorso, forza=False):
|
||||||
|
raise ValueError("file illeggibile")
|
||||||
|
|
||||||
|
config = tmp_path / "cfg"
|
||||||
|
with _client(cartella, config, monkeypatch, Finta()) as c:
|
||||||
|
file = c.post("/camera/parametri/salva", json={"source": SORGENTE}).json()["file"]
|
||||||
|
assert (config / "settaggi" / file).is_file()
|
||||||
|
with _client(cartella, config, monkeypatch, Rotta()) as c:
|
||||||
|
assert c.get("/camera/parametri", params={"source": SORGENTE}).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_apertura_chiede_il_frame_piu_recente(cartella, tmp_path, monkeypatch):
|
||||||
|
# #89: solo_ultimo_frame() una volta sola all'apertura, non a ogni richiesta
|
||||||
|
class ConUltimoFrame(Finta):
|
||||||
|
chiamate = 0
|
||||||
|
|
||||||
|
def solo_ultimo_frame(self, attivo=True):
|
||||||
|
self.chiamate += 1
|
||||||
|
|
||||||
|
finta = ConUltimoFrame()
|
||||||
|
with _client(cartella, tmp_path / "cfg", monkeypatch, finta) as c:
|
||||||
|
for _ in range(3):
|
||||||
|
assert c.get("/camera/parametri", params={"source": SORGENTE}).status_code == 200
|
||||||
|
assert finta.chiamate == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_frame_piu_recente_non_impostabile_non_blocca_l_apertura(cartella, tmp_path, monkeypatch, caplog):
|
||||||
|
# #89: producer senza il nodo -> avviso nel log, apertura riuscita
|
||||||
|
class SenzaNodo(Finta):
|
||||||
|
def solo_ultimo_frame(self, attivo=True):
|
||||||
|
raise CameraStateError("questo producer non espone StreamBufferHandlingMode")
|
||||||
|
|
||||||
|
with caplog.at_level("WARNING"):
|
||||||
|
with _client(cartella, tmp_path / "cfg", monkeypatch, SenzaNodo()) as c:
|
||||||
|
assert c.get("/camera/parametri", params={"source": SORGENTE}).status_code == 200
|
||||||
|
assert "frame piu' recente non impostabile" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_sorgente_senza_solo_ultimo_frame_si_apre(cartella, tmp_path, monkeypatch):
|
||||||
|
# #89: FolderCamera e simili non hanno il metodo: nessun errore
|
||||||
|
assert not hasattr(Finta(), "solo_ultimo_frame")
|
||||||
|
with _client(cartella, tmp_path / "cfg", monkeypatch, Finta()) as c:
|
||||||
|
assert c.get("/camera/parametri", params={"source": SORGENTE}).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# ---- pagina
|
||||||
|
|
||||||
|
def test_acquisizione_alias_della_pagina(client):
|
||||||
|
a = client.get("/acquisizione")
|
||||||
|
c = client.get("/calibrazione")
|
||||||
|
assert a.status_code == 200 and a.text == c.text
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Test della pagina Acquisizione camera (#87): contenuto statico di PAGINA, senza servizio."""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_FILE = Path(__file__).resolve().parents[1] / "calibrazione_pagina.py"
|
||||||
|
_spec = importlib.util.spec_from_file_location("calibrazione_pagina", _FILE)
|
||||||
|
_mod = importlib.util.module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(_mod)
|
||||||
|
PAGINA = _mod.PAGINA
|
||||||
|
|
||||||
|
|
||||||
|
def test_titolo_e_stile_scuro():
|
||||||
|
assert "<title>Acquisizione camera</title>" in PAGINA
|
||||||
|
assert "#0f172a" in PAGINA and "#1e293b" in PAGINA and "#3b82f6" in PAGINA
|
||||||
|
|
||||||
|
|
||||||
|
def test_rotte_usate():
|
||||||
|
for rotta in ("/camera/sources", "/camera/anteprima", "/camera/parametri",
|
||||||
|
"/camera/parametri/salva", "/camera/calibrate", "/camera/calibration"):
|
||||||
|
assert '"' + rotta in PAGINA, rotta
|
||||||
|
|
||||||
|
|
||||||
|
def test_offline_e_senza_finestre_del_browser():
|
||||||
|
for vietato in ("alert(", "confirm(", "prompt(", "http://", "https://"):
|
||||||
|
assert vietato not in PAGINA, vietato
|
||||||
|
|
||||||
|
|
||||||
|
def test_id_principali():
|
||||||
|
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
|
||||||
|
attesi = {"sorgente", "avvia", "ferma", "scatta", "mirinoCasella", "immagine",
|
||||||
|
"nitidezza", "nitidezzaMax", "saturati", "media", "dimensioni",
|
||||||
|
"parametri", "salvaParametri", "quadrato", "acquisisci", "caricaImmagine", "fileImmagine",
|
||||||
|
"mostraCal", "salva", "stato", "statoSorgente"} # #91: carica/elimina sono ora per riga; #92: file e calibrata nell'Acquisizione
|
||||||
|
assert attesi <= ids, attesi - ids
|
||||||
|
# ogni id usato dal JS con $("...") esiste nel markup
|
||||||
|
usati = set(re.findall(r'\$\("([^"]+)"\)', PAGINA))
|
||||||
|
assert usati <= ids, usati - ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_sezioni_e_testi_italiani():
|
||||||
|
for testo in ("Sorgente", "Anteprima", "Parametri camera", "Calibrazione",
|
||||||
|
"Salva come predefiniti", "Carica immagine da file...", "Questa sorgente non ha parametri regolabili",
|
||||||
|
"Mostra immagine calibrata"): # #92
|
||||||
|
assert testo in PAGINA, testo
|
||||||
|
assert "it-IT" in PAGINA # numeri con la virgola
|
||||||
|
assert "Choose File" not in PAGINA
|
||||||
|
|
||||||
|
|
||||||
|
def test_localstorage_protetto():
|
||||||
|
# ogni uso di localStorage sta dentro un try
|
||||||
|
for m in re.finditer(r"localStorage[.]", PAGINA):
|
||||||
|
assert "try {" in PAGINA[max(0, m.start() - 200):m.start()]
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""Test della pagina a due tab (#91, #92): contenuto statico di PAGINA, senza servizio."""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_FILE = Path(__file__).resolve().parents[1] / "calibrazione_pagina.py"
|
||||||
|
_spec = importlib.util.spec_from_file_location("calibrazione_pagina_tab", _FILE)
|
||||||
|
_mod = importlib.util.module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(_mod)
|
||||||
|
PAGINA = _mod.PAGINA
|
||||||
|
|
||||||
|
|
||||||
|
def test_due_tab_con_ruolo_e_selezione():
|
||||||
|
assert re.search(r'<button id="tabAcq"[^>]*role="tab"[^>]*aria-selected="true"', PAGINA)
|
||||||
|
assert re.search(r'<button id="tabCal"[^>]*role="tab"[^>]*aria-selected="false"', PAGINA)
|
||||||
|
assert ">Acquisizione</button>" in PAGINA and ">Calibrazione</button>" in PAGINA
|
||||||
|
assert 'role="tablist"' in PAGINA and 'role="tabpanel"' in PAGINA
|
||||||
|
assert 'aria-selected", cal ?' in PAGINA # la selezione cambia da JS
|
||||||
|
assert '"tmf.acq.tab"' in PAGINA # tab scelta ricordata (via memoria(), protetta)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sorgente_in_cima_poi_riquadri():
|
||||||
|
# #92: Sorgente, anteprima e parametri stanno nel pannello Acquisizione; Sorgente prima dei riquadri
|
||||||
|
i_acq = PAGINA.index('id="pannelloAcq"')
|
||||||
|
i_cal = PAGINA.index('id="pannelloCal"')
|
||||||
|
i_sorg = PAGINA.index('id="sezioneSorgente"')
|
||||||
|
i_ant = PAGINA.index('id="sezioneAnteprima"')
|
||||||
|
i_riq = PAGINA.index('id="riquadri"')
|
||||||
|
assert i_acq < i_sorg < i_ant < i_riq < PAGINA.index('id="sezioneParametri"') < i_cal
|
||||||
|
assert PAGINA.index('id="sezioneCalibrazione"') > i_cal
|
||||||
|
# barra: casella calibrata e mirino; la vecchia casella «Immagine grezza» non c'e' piu'
|
||||||
|
assert 'id="mostraCal"' in PAGINA and "Mostra immagine calibrata" in PAGINA
|
||||||
|
assert 'id="grezzo"' not in PAGINA and "Immagine grezza (senza correzione)" not in PAGINA
|
||||||
|
# riquadri: «Immagine» sempre visibile, «Calibrata» nascosta finche' la casella e' spenta
|
||||||
|
assert re.search(r'<div class="riquadro" id="riquadroCal" hidden>', PAGINA)
|
||||||
|
assert "grezzo: true" in PAGINA
|
||||||
|
|
||||||
|
|
||||||
|
def test_id_del_pannello_calibrazione():
|
||||||
|
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
|
||||||
|
attesi = {"calSorgente", "quadrato", "acquisisci", "nomeCal", "salva", "msgSalva", "calibrazioni",
|
||||||
|
"msgElenco", "stato", "risultato", "overlay",
|
||||||
|
"caricaImmagine", "fileImmagine", "rimuoviImmagine", "riquadroCal", "immagineCal", "mirinoCal"}
|
||||||
|
assert attesi <= ids, attesi - ids
|
||||||
|
usati = set(re.findall(r'\$\("([^"]+)"\)', PAGINA))
|
||||||
|
assert usati <= ids, usati - ids
|
||||||
|
assert len(re.findall(r'id="([^"]+)"', PAGINA)) == len(ids) # nessun id doppio
|
||||||
|
|
||||||
|
|
||||||
|
def test_calibrazione_senza_file_ne_radio():
|
||||||
|
# #92: l'immagine da file e' una sorgente di acquisizione, non un'opzione della calibrazione
|
||||||
|
for sparito in ("origFile", "origSorgente", "scegliFile", "nomeFile", 'type="radio"', "Immagine da file", "Sorgente selezionata"):
|
||||||
|
assert sparito not in PAGINA, sparito
|
||||||
|
# l'unico input file e' nella sezione Sorgente, in cima alla tab Acquisizione
|
||||||
|
assert PAGINA.count('type="file"') == 1
|
||||||
|
assert PAGINA.index('type="file"') < PAGINA.index('id="pannelloCal"')
|
||||||
|
assert "immagine grezza dalla tab Acquisizione" in PAGINA
|
||||||
|
# il calcolo manda solo la sorgente (e il lato), mai il file
|
||||||
|
assert 'f.append("source", sorgente)' in PAGINA and 'f.append("image"' not in PAGINA.split("async function calcola")[1].split("async function salvaNome")[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_testi_del_pannello():
|
||||||
|
for testo in ("Camera o immagine", "Carica immagine da file...", "Rimuovi immagine", "Calcola",
|
||||||
|
"Calibrazioni salvate", "risoluzione diversa", "Calibrata",
|
||||||
|
"Carica", "Elimina", "Scollega", "Sovrascrivi «", "Clic ancora", "Nome",
|
||||||
|
"calibrata: ", "non calibrata"):
|
||||||
|
assert testo in PAGINA, testo
|
||||||
|
|
||||||
|
|
||||||
|
def test_rotte_del_contratto():
|
||||||
|
for rotta in ("/camera/calibrazioni", "/camera/calibrazione-attiva", "/camera/calibrate",
|
||||||
|
"/camera/calibration?source=", "/camera/immagini", "/camera/immagini?nome=",
|
||||||
|
"/camera/anteprima/calibrata"): # #92
|
||||||
|
assert '"' + rotta in PAGINA, rotta
|
||||||
|
for parola in ("calcolo_id", "sovrascrivi", "method: \"PUT\"", "method: \"DELETE\"", "X-Larghezza", "X-Altezza"):
|
||||||
|
assert parola in PAGINA, parola
|
||||||
|
# il calcolo non salva piu': il campo save sparisce
|
||||||
|
assert 'append("save"' not in PAGINA
|
||||||
|
|
||||||
|
|
||||||
|
def test_nessuna_finestra_del_browser_e_offline():
|
||||||
|
for vietato in ("alert(", "confirm(", "prompt(", "http://", "https://"):
|
||||||
|
assert vietato not in PAGINA, vietato
|
||||||
|
|
||||||
|
|
||||||
|
def test_acquisizione_di_87_90_conservata():
|
||||||
|
# anteprima, indicatori, mirino, grezzo, parametri camera e ciclo a 100 ms restano
|
||||||
|
for parola in ("Parametri camera", "Mirino centrale",
|
||||||
|
"Math.max(100 - (Date.now() - t0), 0)", "/camera/anteprima", "/camera/parametri"):
|
||||||
|
assert parola in PAGINA, parola
|
||||||
|
|
||||||
|
|
||||||
|
def test_pagina_stretta():
|
||||||
|
assert "@media (max-width: 500px)" in PAGINA
|
||||||
|
# #91: elenco a schede, niente tabella larga ne' scorrimento orizzontale
|
||||||
|
assert 'id="calibrazioni" class="elenco"' in PAGINA and "overflow-x" not in PAGINA
|
||||||
|
assert "Lettura dei parametri..." in PAGINA
|
||||||
|
|
||||||
|
|
||||||
|
def test_ciclo_sospeso_nella_tab_calibrazione():
|
||||||
|
# #92: entrando in Calibrazione il ciclo si ferma e poi riprende; Calcola aspetta le richieste in volo
|
||||||
|
assert "A.riprendi = true; fermaAnteprima();" in PAGINA
|
||||||
|
assert "if (!cal && A.riprendi)" in PAGINA
|
||||||
|
calcola = PAGINA.split("async function calcola")[1].split("async function salvaNome")[0]
|
||||||
|
assert "fermaAnteprima();" in calcola and "while (A.inVolo)" in calcola
|
||||||
|
|
||||||
|
|
||||||
|
def test_calibrata_dopo_anteprima_e_dopo_carica():
|
||||||
|
# #92: la calibrata segue ogni anteprima grezza riuscita e si aggiorna dopo Carica/Scollega; 404/409 al posto dell'immagine
|
||||||
|
assert "await aggiornaCalibrata(lato);" in PAGINA
|
||||||
|
assert PAGINA.count("aggiornaCalibrata();") >= 3
|
||||||
|
assert "messaggioCalibrata(await dettaglio(r))" in PAGINA
|
||||||
|
assert 'decodeURIComponent(r.headers.get("X-Calibrazione")' in PAGINA
|
||||||
@@ -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"}
|
||||||
|
|||||||
@@ -4,8 +4,11 @@ It does not know, and must not know, whether the server or the station computed
|
|||||||
the numbers: that is what lets a station's configuration change without touching
|
the numbers: that is what lets a station's configuration change without touching
|
||||||
the frontend or the recipes.
|
the frontend or the recipes.
|
||||||
"""
|
"""
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||||
@@ -26,7 +29,7 @@ from src.backend.models.api.vision import (
|
|||||||
from src.backend.models.orm.vision import VisionResult
|
from src.backend.models.orm.vision import VisionResult
|
||||||
from src.backend.models.orm.task import RecipeTask
|
from src.backend.models.orm.task import RecipeTask
|
||||||
from src.backend.models.orm.user import User
|
from src.backend.models.orm.user import User
|
||||||
from src.backend.services import vision_service
|
from src.backend.services import station_service, vision_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/vision", tags=["vision"])
|
router = APIRouter(prefix="/api/vision", tags=["vision"])
|
||||||
|
|
||||||
@@ -185,6 +188,30 @@ def _422(errors: list[str]) -> HTTPException:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _store_validate_assign(
|
||||||
|
task: RecipeTask, graph: dict, placed: dict[str, bytes],
|
||||||
|
) -> tuple[str, dict]:
|
||||||
|
"""Scrive la cartella ricetta, la fa validare dal worker, la assegna al task.
|
||||||
|
|
||||||
|
Condiviso da upload_recipe e dxf-compare con save (#83): stesso
|
||||||
|
comportamento, un solo posto. Non committa. Ritorna (recipe_dir, check).
|
||||||
|
"""
|
||||||
|
# The folder is content-addressed and immutable; writing it before the
|
||||||
|
# validation is harmless (an orphan folder is never referenced) and the
|
||||||
|
# worker needs it on disk to validate meta files.
|
||||||
|
try:
|
||||||
|
recipe_dir = vision_service.store_recipe_folder(task.id, graph, placed)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise _422([str(exc)])
|
||||||
|
check = await vision_service.validate_recipe(graph, recipe_dir)
|
||||||
|
if not check["ok"]:
|
||||||
|
raise _422([str(e) for e in check["errors"]] or ["the recipe is not valid"])
|
||||||
|
|
||||||
|
task.vision_json = graph
|
||||||
|
task.vision_recipe_dir = recipe_dir
|
||||||
|
return recipe_dir, check
|
||||||
|
|
||||||
|
|
||||||
@router.post("/tasks/{task_id}/recipe", response_model=VisionRecipeUploadResponse)
|
@router.post("/tasks/{task_id}/recipe", response_model=VisionRecipeUploadResponse)
|
||||||
async def upload_recipe(
|
async def upload_recipe(
|
||||||
task_id: int,
|
task_id: int,
|
||||||
@@ -218,19 +245,7 @@ async def upload_recipe(
|
|||||||
if errors:
|
if errors:
|
||||||
raise _422(errors)
|
raise _422(errors)
|
||||||
|
|
||||||
# The folder is content-addressed and immutable; writing it before the
|
recipe_dir, check = await _store_validate_assign(task, graph, placed)
|
||||||
# validation is harmless (an orphan folder is never referenced) and the
|
|
||||||
# worker needs it on disk to validate meta files.
|
|
||||||
try:
|
|
||||||
recipe_dir = vision_service.store_recipe_folder(task.id, graph, placed)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise _422([str(exc)])
|
|
||||||
check = await vision_service.validate_recipe(graph, recipe_dir)
|
|
||||||
if not check["ok"]:
|
|
||||||
raise _422([str(e) for e in check["errors"]] or ["the recipe is not valid"])
|
|
||||||
|
|
||||||
task.vision_json = graph
|
|
||||||
task.vision_recipe_dir = recipe_dir
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return VisionRecipeUploadResponse(
|
return VisionRecipeUploadResponse(
|
||||||
task_id=task.id,
|
task_id=task.id,
|
||||||
@@ -325,3 +340,77 @@ async def preview(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
task = await _task_or_404(db, task_id)
|
task = await _task_or_404(db, task_id)
|
||||||
return await vision_service.preview(db, task, await _read_image(image))
|
return await vision_service.preview(db, task, await _read_image(image))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tasks/{task_id}/recipe/dxf-compare")
|
||||||
|
async def create_dxf_compare_recipe(
|
||||||
|
task_id: int,
|
||||||
|
image: UploadFile = File(...),
|
||||||
|
dxf: UploadFile = File(...),
|
||||||
|
station_id: int | None = Form(None, gt=0),
|
||||||
|
px_per_mm: float | None = Form(None, gt=0),
|
||||||
|
band_mm: float = Form(0.1, gt=0),
|
||||||
|
min_points_pct: float = Form(95, ge=0, le=100),
|
||||||
|
polarity: Literal["dark", "light"] = Form("dark"),
|
||||||
|
save: bool = Form(False),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_maker),
|
||||||
|
) -> dict:
|
||||||
|
"""Ricetta DXF compare da un'immagine e un DXF, senza Studio (#83).
|
||||||
|
|
||||||
|
La scala e' `px_per_mm`, o quella della stazione (letta dall'acquisitore).
|
||||||
|
Con save=false si vede il risultato e non si scrive nulla; con save=true la
|
||||||
|
ricetta va sul task come con l'upload, e se il task non ha nessuna quota di
|
||||||
|
visione nasce la quota «Punti in tolleranza».
|
||||||
|
"""
|
||||||
|
task = await _task_or_404(db, task_id)
|
||||||
|
if task.task_type not in vision_service.VISION_TASK_TYPES:
|
||||||
|
raise _422([f"task type {task.task_type!r} does not run a vision recipe"])
|
||||||
|
|
||||||
|
station = None
|
||||||
|
if station_id is not None:
|
||||||
|
station = await station_service.get_station(db, station_id) # 404 se non esiste
|
||||||
|
if px_per_mm is None:
|
||||||
|
calibration, reason = await vision_service.fetch_station_calibration(station)
|
||||||
|
if calibration is None:
|
||||||
|
# irraggiungibile e non calibrata sono due guasti diversi per chi lo legge
|
||||||
|
raise _422([
|
||||||
|
"acquisitore della stazione non raggiungibile"
|
||||||
|
if reason == vision_service.CAL_UNREACHABLE else "stazione non calibrata"
|
||||||
|
])
|
||||||
|
px_per_mm = float(calibration["px_per_mm"])
|
||||||
|
|
||||||
|
image_bytes = await _read_image(image)
|
||||||
|
dxf_bytes = await dxf.read()
|
||||||
|
if not validate_file_size(len(dxf_bytes)):
|
||||||
|
raise _422([f"the DXF exceeds {settings.max_upload_size_mb}MB"])
|
||||||
|
|
||||||
|
body = await vision_service.dxf_compare_recipe(
|
||||||
|
image_bytes, dxf_bytes,
|
||||||
|
px_per_mm=px_per_mm, band_mm=band_mm, min_points_pct=min_points_pct,
|
||||||
|
polarity=polarity,
|
||||||
|
worker_url=(station.vision_worker_url or None) if station is not None else None,
|
||||||
|
)
|
||||||
|
# i file stanno nella ricetta salvata, non nella risposta al client
|
||||||
|
files = body.pop("files")
|
||||||
|
response = {"result": body, "saved": False, "recipe_dir": None, "outputs": None}
|
||||||
|
if not save:
|
||||||
|
return response
|
||||||
|
|
||||||
|
uploads: list[tuple[str, bytes]] = []
|
||||||
|
for name, encoded in files.items():
|
||||||
|
try:
|
||||||
|
uploads.append((name, base64.b64decode(encoded, validate=True)))
|
||||||
|
except (binascii.Error, ValueError, TypeError):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"the vision worker's answer was malformed: file {name!r} is not base64",
|
||||||
|
)
|
||||||
|
placed, errors = vision_service.match_recipe_files(body["graph"], uploads)
|
||||||
|
if errors:
|
||||||
|
raise _422(errors)
|
||||||
|
recipe_dir, check = await _store_validate_assign(task, body["graph"], placed)
|
||||||
|
await vision_service.ensure_points_quote(db, task, min_points_pct)
|
||||||
|
await db.commit()
|
||||||
|
response.update(saved=True, recipe_dir=recipe_dir, outputs=check["outputs"])
|
||||||
|
return response
|
||||||
|
|||||||
+17
-2
@@ -27,6 +27,10 @@ class Settings(BaseSettings):
|
|||||||
db_name: str = "tiemeasureflow"
|
db_name: str = "tiemeasureflow"
|
||||||
db_user: str = "tmflow"
|
db_user: str = "tmflow"
|
||||||
db_password: str = _DEFAULT_DB_PASSWORD
|
db_password: str = _DEFAULT_DB_PASSWORD
|
||||||
|
# Full SQLAlchemy async URL (env DB_URL). When set it wins over the
|
||||||
|
# DB_HOST/DB_PORT/DB_NAME/DB_USER/DB_PASSWORD parts. Used by the Windows
|
||||||
|
# package: sqlite+aiosqlite:///C:/ProgramData/.../tmf.db (forward slashes).
|
||||||
|
db_url: str | None = None
|
||||||
|
|
||||||
# Presentation time zone (DB is UTC, #42)
|
# Presentation time zone (DB is UTC, #42)
|
||||||
display_timezone: str = "Europe/Rome"
|
display_timezone: str = "Europe/Rome"
|
||||||
@@ -78,7 +82,9 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def database_url(self) -> str:
|
def database_url(self) -> str:
|
||||||
"""Async MySQL connection string."""
|
"""Async connection string: DB_URL if set, else the MySQL one."""
|
||||||
|
if self.db_url:
|
||||||
|
return self.db_url
|
||||||
return (
|
return (
|
||||||
f"mysql+asyncmy://{self.db_user}:{self.db_password}"
|
f"mysql+asyncmy://{self.db_user}:{self.db_password}"
|
||||||
f"@{self.db_host}:{self.db_port}/{self.db_name}"
|
f"@{self.db_host}:{self.db_port}/{self.db_name}"
|
||||||
@@ -97,7 +103,12 @@ class Settings(BaseSettings):
|
|||||||
(mounted as a Docker volume), not inside the backend tree.
|
(mounted as a Docker volume), not inside the backend tree.
|
||||||
"""
|
"""
|
||||||
# Path(__file__) = src/backend/config.py → parents[2] = project root
|
# Path(__file__) = src/backend/config.py → parents[2] = project root
|
||||||
return Path(__file__).resolve().parents[2] / self.upload_dir
|
# UPLOAD_DIR may be absolute (Windows package: C:/ProgramData/...):
|
||||||
|
# in that case it is used as is instead of being joined to the root.
|
||||||
|
configured = Path(self.upload_dir)
|
||||||
|
if configured.is_absolute():
|
||||||
|
return configured
|
||||||
|
return Path(__file__).resolve().parents[2] / configured
|
||||||
|
|
||||||
# Always resolve .env against the project root regardless of cwd
|
# Always resolve .env against the project root regardless of cwd
|
||||||
# (pydantic-settings would otherwise treat the path as cwd-relative).
|
# (pydantic-settings would otherwise treat the path as cwd-relative).
|
||||||
@@ -122,6 +133,10 @@ class Settings(BaseSettings):
|
|||||||
"SERVER_SECRET_KEY is still the committed default value; "
|
"SERVER_SECRET_KEY is still the committed default value; "
|
||||||
"set it before running with APP_ENV=" + self.app_env
|
"set it before running with APP_ENV=" + self.app_env
|
||||||
)
|
)
|
||||||
|
# A SQLite file has no DB password: the default-password guard only
|
||||||
|
# makes sense for the MySQL connection built from the DB_* parts.
|
||||||
|
if self.db_url and self.db_url.startswith("sqlite"):
|
||||||
|
return self
|
||||||
if self.db_password == _DEFAULT_DB_PASSWORD:
|
if self.db_password == _DEFAULT_DB_PASSWORD:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"DB_PASSWORD is still the committed default value; "
|
"DB_PASSWORD is still the committed default value; "
|
||||||
|
|||||||
+25
-8
@@ -1,6 +1,7 @@
|
|||||||
"""Async SQLAlchemy database engine and session management."""
|
"""Async SQLAlchemy database engine and session management."""
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
|
|
||||||
|
from sqlalchemy import event
|
||||||
from sqlalchemy.ext.asyncio import (
|
from sqlalchemy.ext.asyncio import (
|
||||||
AsyncSession,
|
AsyncSession,
|
||||||
async_sessionmaker,
|
async_sessionmaker,
|
||||||
@@ -10,14 +11,30 @@ from sqlalchemy.orm import DeclarativeBase
|
|||||||
|
|
||||||
from src.backend.config import settings
|
from src.backend.config import settings
|
||||||
|
|
||||||
# Create async engine
|
_IS_SQLITE = settings.database_url.startswith("sqlite")
|
||||||
engine = create_async_engine(
|
|
||||||
settings.database_url,
|
# Create async engine. MySQL gets a connection pool; SQLite (local file of the
|
||||||
echo=False,
|
# Windows package) does not accept pool_size/max_overflow/pool_recycle.
|
||||||
pool_size=10,
|
if _IS_SQLITE:
|
||||||
max_overflow=20,
|
engine = create_async_engine(settings.database_url, echo=False)
|
||||||
pool_recycle=3600,
|
|
||||||
)
|
@event.listens_for(engine.sync_engine, "connect")
|
||||||
|
def _sqlite_pragmas(dbapi_connection, _connection_record) -> None:
|
||||||
|
"""Per-connection PRAGMAs: SQLite ignores FKs unless asked, and WAL +
|
||||||
|
busy_timeout let the web, vision and backend threads share the file."""
|
||||||
|
cursor = dbapi_connection.cursor()
|
||||||
|
cursor.execute("PRAGMA foreign_keys=ON")
|
||||||
|
cursor.execute("PRAGMA journal_mode=WAL")
|
||||||
|
cursor.execute("PRAGMA busy_timeout=5000")
|
||||||
|
cursor.close()
|
||||||
|
else:
|
||||||
|
engine = create_async_engine(
|
||||||
|
settings.database_url,
|
||||||
|
echo=False,
|
||||||
|
pool_size=10,
|
||||||
|
max_overflow=20,
|
||||||
|
pool_recycle=3600,
|
||||||
|
)
|
||||||
|
|
||||||
# Session factory
|
# Session factory
|
||||||
async_session_factory = async_sessionmaker(
|
async_session_factory = async_sessionmaker(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Report generation service — PDF reports via Jinja2 + Plotly/Kaleido + WeasyPrint."""
|
"""Report generation service — PDF reports via Jinja2 + Plotly/Kaleido + WeasyPrint."""
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
|
import tempfile
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from src.backend.utils.tempo import to_local, utcnow
|
from src.backend.utils.tempo import to_local, utcnow
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -25,6 +26,23 @@ from src.backend.services.spc_service import (
|
|||||||
compute_summary,
|
compute_summary,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Kaleido 1.x aggiunge alla pagina di render uno <script> MathJax dal CDN (cdnjs) anche se i grafici
|
||||||
|
# non usano LaTeX: senza Internet (stand, PC di produzione) ogni grafico aspetta il timeout di rete
|
||||||
|
# (~19 s con rete che scarta i pacchetti, TieMeasureFlow#78). I grafici qui sotto non hanno formule:
|
||||||
|
# si passa a Kaleido un file locale vuoto al posto del CDN. Il file sta nella cartella temporanea.
|
||||||
|
def _mathjax_vuoto() -> str:
|
||||||
|
"""Percorso di un .js vuoto da usare come MathJax: nessuna richiesta di rete per i grafici."""
|
||||||
|
path = Path(tempfile.gettempdir()) / "tmf-mathjax-vuoto.js"
|
||||||
|
if not path.exists():
|
||||||
|
path.write_text("/* MathJax non usato: grafici senza LaTeX */", encoding="utf-8")
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
pio.defaults.mathjax = _mathjax_vuoto()
|
||||||
|
except (AttributeError, OSError): # plotly vecchio senza pio.defaults o temp non scrivibile: resta il CDN
|
||||||
|
pass
|
||||||
|
|
||||||
# Jinja2 environment for report templates
|
# Jinja2 environment for report templates
|
||||||
_templates_dir = Path(__file__).parent.parent / "templates" / "reports"
|
_templates_dir = Path(__file__).parent.parent / "templates" / "reports"
|
||||||
_jinja_env = Environment(
|
_jinja_env = Environment(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import math
|
import math
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -24,6 +25,8 @@ from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
|||||||
from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
|
from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
|
||||||
from src.backend.services import measurement_service
|
from src.backend.services import measurement_service
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Worker responses whose status and reason are forwarded as-is rather than
|
# Worker responses whose status and reason are forwarded as-is rather than
|
||||||
# flattened into a generic 502 - see `_call_worker`.
|
# flattened into a generic 502 - see `_call_worker`.
|
||||||
_FORWARDED_WORKER_STATUSES = frozenset({
|
_FORWARDED_WORKER_STATUSES = frozenset({
|
||||||
@@ -140,6 +143,136 @@ async def _call_worker(
|
|||||||
return report
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
async def call_worker_multipart(
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
files: dict,
|
||||||
|
data: dict,
|
||||||
|
worker_url: str | None = None,
|
||||||
|
timeout: float = 120.0,
|
||||||
|
) -> dict:
|
||||||
|
"""POST multipart generico al worker (#83): /recipes/dxf-compare.
|
||||||
|
|
||||||
|
Stessa traduzione degli errori di `_call_worker`: 504 sul timeout, 502 sul
|
||||||
|
trasporto o su una risposta non JSON-oggetto; 422/503/504 del worker si
|
||||||
|
inoltrano cosi' come sono. Worker della stazione o, se manca, il centrale.
|
||||||
|
"""
|
||||||
|
base = (worker_url or settings.vision_worker_url).rstrip("/")
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
response = await client.post(f"{base}{path}", files=files, data=data)
|
||||||
|
except httpx.TimeoutException as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
||||||
|
detail=f"the vision worker did not answer in time: {exc} ({base})",
|
||||||
|
) from exc
|
||||||
|
except (httpx.TransportError, httpx.HTTPError) as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"the vision worker is unreachable: {exc} ({base})",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if response.status_code in _FORWARDED_WORKER_STATUSES:
|
||||||
|
try:
|
||||||
|
detail = response.json().get("detail", response.text)
|
||||||
|
except ValueError:
|
||||||
|
detail = response.text
|
||||||
|
raise HTTPException(status_code=response.status_code, detail=detail)
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"the vision worker refused the job: {response.text}",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
body = response.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"the vision worker's answer was malformed: not valid JSON ({exc})",
|
||||||
|
) from exc
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail="the vision worker's answer was malformed: expected a JSON object",
|
||||||
|
)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
async def dxf_compare_recipe(
|
||||||
|
image_bytes: bytes, dxf_bytes: bytes, *, px_per_mm: float, band_mm: float,
|
||||||
|
min_points_pct: float, polarity: str, worker_url: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""W2: ricetta dxf-compare da immagine + DXF (#83). Sostituita dai test."""
|
||||||
|
body = await call_worker_multipart(
|
||||||
|
"/recipes/dxf-compare",
|
||||||
|
files={
|
||||||
|
"image": ("frame.png", image_bytes, "image/png"),
|
||||||
|
"dxf": ("disegno.dxf", dxf_bytes, "application/octet-stream"),
|
||||||
|
},
|
||||||
|
data={
|
||||||
|
"px_per_mm": str(px_per_mm), "band_mm": str(band_mm),
|
||||||
|
"min_points_pct": str(min_points_pct), "polarity": polarity,
|
||||||
|
},
|
||||||
|
worker_url=worker_url,
|
||||||
|
)
|
||||||
|
# forma minima del contratto W2: senza graph e files non c'e' nulla da salvare
|
||||||
|
if not isinstance(body.get("graph"), dict) or not isinstance(body.get("files"), dict):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail="the vision worker's answer was malformed: missing graph or files",
|
||||||
|
)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
# Stati di fetch_station_calibration (#83)
|
||||||
|
CAL_OK = "ok"
|
||||||
|
CAL_NO_CAMERA = "no_camera" # la stazione non ha camera_url/camera_source
|
||||||
|
CAL_NOT_CALIBRATED = "not_calibrated" # l'acquisitore risponde 404 (o un valore inutilizzabile)
|
||||||
|
CAL_UNREACHABLE = "unreachable" # l'acquisitore non risponde
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_station_calibration(station: Station | None) -> tuple[dict | None, str]:
|
||||||
|
"""Legge la calibrazione dall'acquisitore della stazione (C1, #83).
|
||||||
|
|
||||||
|
GET {camera_url}/camera/calibration?source={camera_source}. La calibrazione
|
||||||
|
vive nell'acquisitore: TMF la legge, non la tiene. Ritorna (dict, "ok")
|
||||||
|
oppure (None, motivo) per distinguere «non calibrata» da «irraggiungibile».
|
||||||
|
Un px_per_mm non finito o <= 0 conta come non calibrata.
|
||||||
|
"""
|
||||||
|
if station is None or not station.camera_url or not station.camera_source:
|
||||||
|
return None, CAL_NO_CAMERA
|
||||||
|
url = f"{station.camera_url.rstrip('/')}/camera/calibration"
|
||||||
|
try:
|
||||||
|
# timeout breve: e' una lettura di servizio, non deve bloccare l'execute
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
response = await client.get(url, params={"source": station.camera_source})
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
logger.warning("acquisitore della stazione %s non raggiungibile: %s", station.code, exc)
|
||||||
|
return None, CAL_UNREACHABLE
|
||||||
|
if response.status_code == status.HTTP_404_NOT_FOUND:
|
||||||
|
return None, CAL_NOT_CALIBRATED
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.warning(
|
||||||
|
"acquisitore della stazione %s: HTTP %s", station.code, response.status_code,
|
||||||
|
)
|
||||||
|
return None, CAL_UNREACHABLE
|
||||||
|
try:
|
||||||
|
body = response.json()
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("acquisitore della stazione %s: risposta non JSON", station.code)
|
||||||
|
return None, CAL_UNREACHABLE
|
||||||
|
px_per_mm = body.get("px_per_mm") if isinstance(body, dict) else None
|
||||||
|
if not (_is_measurable(px_per_mm) and px_per_mm > 0):
|
||||||
|
return None, CAL_NOT_CALIBRATED
|
||||||
|
return body, CAL_OK
|
||||||
|
|
||||||
|
|
||||||
|
async def get_station_calibration(station: Station | None) -> dict | None:
|
||||||
|
"""La calibrazione C1 della stazione, o None in ogni altro caso (#83)."""
|
||||||
|
calibration, _ = await fetch_station_calibration(station)
|
||||||
|
return calibration
|
||||||
|
|
||||||
|
|
||||||
async def validate_recipe(graph: dict, recipe_dir: str | None) -> dict:
|
async def validate_recipe(graph: dict, recipe_dir: str | None) -> dict:
|
||||||
"""Ask the worker's /validate whether a v2 recipe is loadable (#46).
|
"""Ask the worker's /validate whether a v2 recipe is loadable (#46).
|
||||||
|
|
||||||
@@ -365,11 +498,14 @@ async def execute_task(
|
|||||||
)
|
)
|
||||||
# Worker della stazione della sessione di produzione (#45); senza run o senza URL: centrale
|
# Worker della stazione della sessione di produzione (#45); senza run o senza URL: centrale
|
||||||
station_url = None
|
station_url = None
|
||||||
|
# Calibrazione letta dall'acquisitore della stazione (#83); None = non c'e'
|
||||||
|
calibration = None
|
||||||
if run is not None:
|
if run is not None:
|
||||||
station = (await db.execute(
|
station = (await db.execute(
|
||||||
select(Station).where(Station.id == run.station_id)
|
select(Station).where(Station.id == run.station_id)
|
||||||
)).scalar_one_or_none()
|
)).scalar_one_or_none()
|
||||||
station_url = (station.vision_worker_url or None) if station is not None else None
|
station_url = (station.vision_worker_url or None) if station is not None else None
|
||||||
|
calibration = await get_station_calibration(station)
|
||||||
|
|
||||||
if not task.vision_json:
|
if not task.vision_json:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -419,11 +555,15 @@ async def execute_task(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Nessuna fonte di calibrazione esiste oggi (ne' task ne' stazione): non si
|
# La calibrazione viene dall'acquisitore della stazione (#83); se non c'e',
|
||||||
# passa `calibration`, la ricetta porta la sua (#46).
|
# non si passa `calibration` e la ricetta porta la sua (#46).
|
||||||
|
worker_kwargs: dict = {}
|
||||||
|
if calibration is not None:
|
||||||
|
worker_kwargs["calibration"] = {"px_per_mm": float(calibration["px_per_mm"])}
|
||||||
report = await _call_worker(
|
report = await _call_worker(
|
||||||
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
|
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
|
||||||
worker_url=station_url, # #45
|
worker_url=station_url, # #45
|
||||||
|
**worker_kwargs,
|
||||||
)
|
)
|
||||||
outputs = report["outputs"]
|
outputs = report["outputs"]
|
||||||
|
|
||||||
@@ -457,6 +597,8 @@ async def execute_task(
|
|||||||
station_id=run.station_id if run is not None else None,
|
station_id=run.station_id if run is not None else None,
|
||||||
worker_url=(station_url or settings.vision_worker_url).rstrip("/"),
|
worker_url=(station_url or settings.vision_worker_url).rstrip("/"),
|
||||||
graph_snapshot=task.vision_json,
|
graph_snapshot=task.vision_json,
|
||||||
|
# copiata come il grafo (#83): ricalibrare dopo non cambia cio' che e' stato misurato
|
||||||
|
calibration_snapshot=calibration,
|
||||||
duration_ms=report.get("duration_ms"),
|
duration_ms=report.get("duration_ms"),
|
||||||
)
|
)
|
||||||
db.add(result)
|
db.add(result)
|
||||||
@@ -601,3 +743,42 @@ async def preview(db: AsyncSession, task: RecipeTask, image_bytes: bytes) -> dic
|
|||||||
return await _call_worker(
|
return await _call_worker(
|
||||||
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
|
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_points_quote(
|
||||||
|
db: AsyncSession, task: RecipeTask, min_points_pct: float,
|
||||||
|
) -> bool:
|
||||||
|
"""Crea la quota «Punti in tolleranza» se il task non ha nessuna quota di visione (#83).
|
||||||
|
|
||||||
|
Una sola volta: se esiste gia' una quota con `vision_output`, non si tocca.
|
||||||
|
Se la versione ha gia' misure non si scrive (il copy-on-write sta nel router
|
||||||
|
dei task, qui non si aggira): ritorna False. Ritorna True se l'ha creata.
|
||||||
|
"""
|
||||||
|
from src.backend.services import recipe_service # import locale: evita cicli
|
||||||
|
|
||||||
|
existing = (await db.execute(
|
||||||
|
select(RecipeSubtask.id)
|
||||||
|
.where(RecipeSubtask.task_id == task.id)
|
||||||
|
.where(RecipeSubtask.vision_output.is_not(None))
|
||||||
|
.limit(1)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
return False
|
||||||
|
if await recipe_service.version_has_measurements(db, task.version_id):
|
||||||
|
return False
|
||||||
|
markers = (await db.execute(
|
||||||
|
select(RecipeSubtask.marker_number).where(RecipeSubtask.task_id == task.id)
|
||||||
|
)).scalars().all()
|
||||||
|
db.add(RecipeSubtask(
|
||||||
|
task_id=task.id,
|
||||||
|
marker_number=max(markers, default=0) + 1, # unico per task (uq_task_marker)
|
||||||
|
description="Punti in tolleranza",
|
||||||
|
nominal=100,
|
||||||
|
# limiti assoluti come nel resto delle quote: passa se punti% >= min_points_pct
|
||||||
|
ltl=min_points_pct,
|
||||||
|
utl=100,
|
||||||
|
unit="%",
|
||||||
|
vision_output="dxf.points_in_pct",
|
||||||
|
))
|
||||||
|
await db.flush()
|
||||||
|
return True
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"""Report PDF veri su Windows (WeasyPrint + DLL GTK + Kaleido con Edge/Chrome), offline.
|
||||||
|
|
||||||
|
Pacchetto Windows (TieMeasureFlow#78): senza Internet i report devono uscire lo stesso.
|
||||||
|
Il test gira solo su Windows, con le DLL di packaging/windows/pdf/prendi-gtk.ps1 e un browser
|
||||||
|
Chromium (Edge o Chrome) installati; altrimenti viene saltato.
|
||||||
|
|
||||||
|
Il conftest dei test sostituisce WeasyPrint con un mock: qui serve quello vero, quindi la
|
||||||
|
generazione gira in un sottoprocesso che importa weasyprint PRIMA del conftest (il mock scatta
|
||||||
|
solo se weasyprint non e' gia' in sys.modules).
|
||||||
|
|
||||||
|
Come preparare le DLL: powershell -File packaging/windows/pdf/prendi-gtk.ps1 -Destinazione packaging/windows/pdf/gtk
|
||||||
|
(oppure WEASYPRINT_DLL_DIRECTORIES gia' impostata su una cartella con le DLL).
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parents[3]
|
||||||
|
GTK_DEFAULT = REPO / "packaging" / "windows" / "pdf" / "gtk"
|
||||||
|
|
||||||
|
# Browser Chromium cercati in ordine (Edge e' presente su ogni Windows 10/11).
|
||||||
|
_BROWSER_CANDIDATI = [
|
||||||
|
r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
|
||||||
|
r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
|
||||||
|
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
|
||||||
|
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _cartella_gtk() -> Path | None:
|
||||||
|
"""Cartella DLL: WEASYPRINT_DLL_DIRECTORIES (primo elemento) o quella di prendi-gtk.ps1."""
|
||||||
|
env = os.environ.get("WEASYPRINT_DLL_DIRECTORIES", "").split(";")[0]
|
||||||
|
for c in (Path(env) if env else None, GTK_DEFAULT):
|
||||||
|
if c and (c / "libpango-1.0-0.dll").exists():
|
||||||
|
return c
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _browser() -> str | None:
|
||||||
|
env = os.environ.get("BROWSER_PATH")
|
||||||
|
if env and Path(env).exists():
|
||||||
|
return env
|
||||||
|
return next((b for b in _BROWSER_CANDIDATI if Path(b).exists()), None)
|
||||||
|
|
||||||
|
|
||||||
|
def _tz_ok() -> bool:
|
||||||
|
"""Windows non ha il database IANA: serve il pacchetto tzdata (to_local usa ZoneInfo)."""
|
||||||
|
try:
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
ZoneInfo("Europe/Rome")
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
pytestmark = [
|
||||||
|
pytest.mark.skipif(sys.platform != "win32", reason="solo Windows"),
|
||||||
|
pytest.mark.skipif(_cartella_gtk() is None, reason="DLL GTK assenti: lancia prendi-gtk.ps1"),
|
||||||
|
pytest.mark.skipif(_browser() is None, reason="nessun Edge/Chrome installato"),
|
||||||
|
# Senza tzdata i report falliscono su Windows (ZoneInfoNotFoundError): il pacchetto deve dichiararlo.
|
||||||
|
pytest.mark.skipif(not _tz_ok(), reason="manca tzdata: aggiungere 'tzdata; sys_platform == \"win32\"' a pyproject"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Sottoprocesso: dati veri nel DB SQLite in memoria del conftest, poi i due report reali.
|
||||||
|
_WORKER = r"""
|
||||||
|
import asyncio, json, sys
|
||||||
|
sys.path.insert(0, sys.argv[1])
|
||||||
|
import weasyprint # VERO, prima del conftest (che altrimenti lo sostituisce con un mock)
|
||||||
|
assert not type(weasyprint.HTML).__module__.startswith("unittest"), "WeasyPrint mockato"
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from src.backend.tests.conftest import Base, TestSessionFactory, test_engine, _create_user, create_test_recipe
|
||||||
|
from src.backend.models.orm.measurement import Measurement
|
||||||
|
from src.backend.models.orm.task import RecipeSubtask
|
||||||
|
from src.backend.services import report_service
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
VALORI = [10.02, 9.98, 10.05, 10.11, 9.94, 10.0, 10.07, 9.96, 10.03, 10.09, 9.99, 10.04, 10.38, 10.01, 9.97]
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
async with test_engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
async with TestSessionFactory() as db:
|
||||||
|
user = await _create_user(db, "metrologo", roles=["Metrologist"])
|
||||||
|
recipe = await create_test_recipe(db, user.id)
|
||||||
|
version = recipe.versions[0]
|
||||||
|
sub = (await db.execute(select(RecipeSubtask))).scalars().first()
|
||||||
|
t0 = datetime.utcnow() - timedelta(hours=1)
|
||||||
|
for i, v in enumerate(VALORI):
|
||||||
|
pf = "warning" if v > 10.3 else "pass"
|
||||||
|
db.add(Measurement(subtask_id=sub.id, version_id=version.id, measured_by=user.id,
|
||||||
|
value=v, pass_fail=pf, measured_at=t0 + timedelta(minutes=i)))
|
||||||
|
await db.commit()
|
||||||
|
spc = await report_service.generate_spc_report(db, recipe.id, sub.id)
|
||||||
|
mis = await report_service.generate_measurement_report(db, recipe.id)
|
||||||
|
open(sys.argv[2] + "/spc.pdf", "wb").write(spc)
|
||||||
|
open(sys.argv[2] + "/misure.pdf", "wb").write(mis)
|
||||||
|
# MathJax locale: senza, Kaleido chiede lo script al CDN e senza Internet ogni grafico attende ~19 s.
|
||||||
|
from pathlib import Path
|
||||||
|
mj = report_service.pio.defaults.mathjax
|
||||||
|
print(json.dumps({"ok": True, "mathjax_locale": bool(mj) and Path(mj).is_file()}))
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def pdf_generati(tmp_path_factory):
|
||||||
|
"""Genera i due PDF in un sottoprocesso con proxy finto: se serve Internet, fallisce."""
|
||||||
|
out = tmp_path_factory.mktemp("report_win")
|
||||||
|
env = dict(os.environ)
|
||||||
|
env.update(
|
||||||
|
WEASYPRINT_DLL_DIRECTORIES=str(_cartella_gtk()),
|
||||||
|
BROWSER_PATH=_browser(),
|
||||||
|
# Proxy morto per la parte Python (Chrome/Edge usano invece il proxy di sistema: vedi LEGGIMI.md).
|
||||||
|
HTTP_PROXY="http://127.0.0.1:9", HTTPS_PROXY="http://127.0.0.1:9",
|
||||||
|
PYTHONIOENCODING="utf-8",
|
||||||
|
)
|
||||||
|
# Il sottoprocesso non deve leggere il .env di sviluppo ne' un DB reale.
|
||||||
|
env.pop("DB_URL", None)
|
||||||
|
res = subprocess.run(
|
||||||
|
[sys.executable, "-W", "ignore", "-c", _WORKER, str(REPO), str(out)],
|
||||||
|
cwd=REPO, env=env, capture_output=True, text=True, timeout=180,
|
||||||
|
)
|
||||||
|
assert res.returncode == 0, f"sottoprocesso fallito:\n{res.stdout}\n{res.stderr}"
|
||||||
|
esito = json.loads(res.stdout.strip().splitlines()[-1])
|
||||||
|
assert esito["ok"] is True
|
||||||
|
assert esito["mathjax_locale"], "Kaleido userebbe MathJax dal CDN: niente rete = report lenti"
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _pdf(path: Path):
|
||||||
|
import pdfplumber
|
||||||
|
|
||||||
|
return pdfplumber.open(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_spc_ha_carta_di_controllo_e_istogramma(pdf_generati):
|
||||||
|
"""Il report SPC e' un PDF vero con una pagina almeno e i due grafici disegnati."""
|
||||||
|
path = pdf_generati / "spc.pdf"
|
||||||
|
assert path.read_bytes()[:5] == b"%PDF-"
|
||||||
|
with _pdf(path) as pdf:
|
||||||
|
assert len(pdf.pages) >= 1
|
||||||
|
testo = "\n".join(p.extract_text() or "" for p in pdf.pages)
|
||||||
|
# I titoli stanno dentro l'SVG di Plotly: se ci sono, Kaleido ha reso e WeasyPrint ha disegnato.
|
||||||
|
assert "Control Chart" in testo
|
||||||
|
assert "Histogram" in testo
|
||||||
|
# Grafico presente = segni vettoriali (tracce, assi, barre) oltre al testo.
|
||||||
|
segni = sum(len(p.curves) + len(p.lines) + len(p.rects) for p in pdf.pages)
|
||||||
|
assert segni > 40, f"troppo pochi segni vettoriali ({segni}): grafico assente?"
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_misure_e_pdf_vero(pdf_generati):
|
||||||
|
"""Anche il report a tabella (solo WeasyPrint, niente Kaleido) esce con le righe misurate."""
|
||||||
|
path = pdf_generati / "misure.pdf"
|
||||||
|
assert path.read_bytes()[:5] == b"%PDF-"
|
||||||
|
with _pdf(path) as pdf:
|
||||||
|
assert len(pdf.pages) >= 1
|
||||||
|
testo = "\n".join(p.extract_text() or "" for p in pdf.pages)
|
||||||
|
assert "10.3800" in testo # il valore anomalo e' in tabella
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
"""Test di scripts/seed_fiera.py (issue #85): il seed del PC della fiera.
|
||||||
|
|
||||||
|
Lo script non e' un modulo del pacchetto: si carica da file con importlib, come
|
||||||
|
test_seed_collaudo. La parte "dato" gira sul DB in memoria dei test; l'ultimo test
|
||||||
|
percorre invece la strada vera (file SQLite con `alembic upgrade head` + --db).
|
||||||
|
"""
|
||||||
|
import importlib.util
|
||||||
|
import sqlite3
|
||||||
|
from argparse import Namespace
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from alembic import command
|
||||||
|
from alembic.config import Config
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from src.backend.config import Settings, settings
|
||||||
|
from src.backend.models.orm.measurement import Measurement
|
||||||
|
from src.backend.models.orm.recipe import Recipe
|
||||||
|
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||||
|
from src.backend.models.orm.user import User
|
||||||
|
from src.backend.services.auth_service import authenticate_user
|
||||||
|
from src.backend.tests.conftest import TestSessionFactory, test_engine
|
||||||
|
|
||||||
|
_SCRIPT_PATH = Path(__file__).resolve().parents[3] / "scripts" / "seed_fiera.py"
|
||||||
|
_ALEMBIC_INI = Path(__file__).resolve().parents[1] / "migrations" / "alembic.ini"
|
||||||
|
FINE = date(2026, 10, 22) # data fissa: il test non dipende dal giorno in cui gira
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def seed_fiera(monkeypatch):
|
||||||
|
spec = importlib.util.spec_from_file_location("seed_fiera", _SCRIPT_PATH)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
monkeypatch.setattr(module, "async_session_factory", TestSessionFactory)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
async def _utente(db_session, username) -> User:
|
||||||
|
return (await db_session.execute(
|
||||||
|
select(User).where(User.username == username)
|
||||||
|
)).scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
class TestSeedFiera:
|
||||||
|
async def test_utenti_ruoli_e_login(self, seed_fiera, db_session):
|
||||||
|
assert await seed_fiera.seed(False, False, FINE) == 0
|
||||||
|
|
||||||
|
attesi = {
|
||||||
|
"adriano": ("123", True),
|
||||||
|
"maker": ("123", False),
|
||||||
|
"capoturno": ("123", False),
|
||||||
|
"operatore": ("123", False),
|
||||||
|
}
|
||||||
|
for username, (password, admin) in attesi.items():
|
||||||
|
utente = await authenticate_user(db_session, username, password)
|
||||||
|
assert utente is not None, username
|
||||||
|
assert utente.is_admin is admin
|
||||||
|
assert (await _utente(db_session, "maker")).roles == ["Maker"]
|
||||||
|
assert "Supervisor" in (await _utente(db_session, "capoturno")).roles
|
||||||
|
assert (await _utente(db_session, "operatore")).roles == ["MeasurementTec"]
|
||||||
|
assert {"Maker", "Metrologist"} <= set((await _utente(db_session, "adriano")).roles)
|
||||||
|
|
||||||
|
async def test_setup_risulta_completato(self, seed_fiera, client, monkeypatch):
|
||||||
|
import src.backend.api.routers.setup as setup_mod
|
||||||
|
monkeypatch.setattr(settings, "setup_password", "test-setup-password")
|
||||||
|
monkeypatch.setattr(setup_mod, "engine", test_engine)
|
||||||
|
monkeypatch.setattr(setup_mod, "async_session_factory", TestSessionFactory)
|
||||||
|
|
||||||
|
# prima del seed il frontend vede il setup da fare, dopo no
|
||||||
|
assert (await client.get("/api/setup/status")).json() == {"initialised": False}
|
||||||
|
await seed_fiera.seed(False, False, FINE)
|
||||||
|
assert (await client.get("/api/setup/status")).json() == {"initialised": True}
|
||||||
|
|
||||||
|
async def test_stazione_e_ricette(self, seed_fiera, db_session):
|
||||||
|
await seed_fiera.seed(False, False, FINE)
|
||||||
|
|
||||||
|
stazione = (await db_session.execute(
|
||||||
|
select(Station).where(Station.code == "ST-DEFAULT")
|
||||||
|
)).scalar_one()
|
||||||
|
assert stazione.camera_url == "http://127.0.0.1:8765"
|
||||||
|
assert stazione.camera_source == "camera:601020"
|
||||||
|
|
||||||
|
assegnate = (await db_session.execute(
|
||||||
|
select(Recipe.code)
|
||||||
|
.join(StationRecipeAssignment, StationRecipeAssignment.recipe_id == Recipe.id)
|
||||||
|
.where(StationRecipeAssignment.station_id == stazione.id)
|
||||||
|
)).scalars().all()
|
||||||
|
assert sorted(assegnate) == ["FIERA-ALBERO", "FIERA-DXF", "FIERA-FLANGIA"]
|
||||||
|
|
||||||
|
# la ricetta DXF ha un task dxf_compare e nessun grafo di visione
|
||||||
|
from src.backend.models.orm.task import RecipeTask
|
||||||
|
dxf = (await db_session.execute(
|
||||||
|
select(RecipeTask).where(RecipeTask.task_type == "dxf_compare")
|
||||||
|
)).scalars().all()
|
||||||
|
assert len(dxf) == 1 and dxf[0].vision_json is None
|
||||||
|
|
||||||
|
async def test_misure_nel_numero_atteso(self, seed_fiera, db_session):
|
||||||
|
await seed_fiera.seed(False, False, FINE)
|
||||||
|
|
||||||
|
# ogni pezzo ha una misura per quota: pezzi 150-300 per ricetta
|
||||||
|
for codice, quote in (("FIERA-FLANGIA", 5), ("FIERA-ALBERO", 4)):
|
||||||
|
misure = (await db_session.execute(
|
||||||
|
select(func.count(Measurement.id))
|
||||||
|
.where(Measurement.version_id.in_(
|
||||||
|
select(seed_fiera.RecipeVersion.id)
|
||||||
|
.join(Recipe, Recipe.id == seed_fiera.RecipeVersion.recipe_id)
|
||||||
|
.where(Recipe.code == codice)
|
||||||
|
))
|
||||||
|
)).scalar_one()
|
||||||
|
assert misure % quote == 0
|
||||||
|
assert 150 <= misure // quote <= 300, (codice, misure // quote)
|
||||||
|
|
||||||
|
# lun-ven soltanto, e le misure compaiono nelle produzioni
|
||||||
|
giorni = (await db_session.execute(
|
||||||
|
select(seed_fiera.ProductionRun.started_at)
|
||||||
|
)).scalars().all()
|
||||||
|
assert giorni and all(g.weekday() < 5 for g in giorni)
|
||||||
|
senza_run = (await db_session.execute(
|
||||||
|
select(func.count(Measurement.id)).where(Measurement.production_run_id.is_(None))
|
||||||
|
)).scalar_one()
|
||||||
|
assert senza_run == 0
|
||||||
|
|
||||||
|
async def test_statistiche_numeriche(self, seed_fiera, client, db_session):
|
||||||
|
await seed_fiera.seed(False, False, FINE)
|
||||||
|
adriano = await _utente(db_session, "adriano")
|
||||||
|
from src.backend.services.auth_service import issue_api_key
|
||||||
|
chiave = await issue_api_key(db_session, adriano)
|
||||||
|
intestazioni = {"X-API-Key": chiave}
|
||||||
|
|
||||||
|
flangia = (await db_session.execute(
|
||||||
|
select(Recipe).where(Recipe.code == "FIERA-FLANGIA")
|
||||||
|
)).scalar_one()
|
||||||
|
quote = (await client.get(
|
||||||
|
"/api/statistics/subtasks", headers=intestazioni,
|
||||||
|
params={"recipe_id": flangia.id},
|
||||||
|
)).json()
|
||||||
|
assert len(quote) == 5
|
||||||
|
cpk = []
|
||||||
|
for quota in quote:
|
||||||
|
risposta = await client.get(
|
||||||
|
"/api/statistics/capability", headers=intestazioni,
|
||||||
|
params={"recipe_id": flangia.id, "subtask_id": quota["id"]},
|
||||||
|
)
|
||||||
|
assert risposta.status_code == 200
|
||||||
|
d = risposta.json()
|
||||||
|
for campo in ("mean", "std_dev", "cp", "cpk"):
|
||||||
|
assert isinstance(d[campo], (int, float)), (quota["id"], campo)
|
||||||
|
cpk.append(d["cpk"])
|
||||||
|
# Cpk diversi per quota: una capace, una al limite o sotto
|
||||||
|
assert max(cpk) > 1.4
|
||||||
|
assert min(cpk) < 1.1
|
||||||
|
|
||||||
|
async def test_secondo_giro_non_duplica_e_replace_rifa(self, seed_fiera, db_session):
|
||||||
|
async def conta():
|
||||||
|
return [
|
||||||
|
(await db_session.execute(select(func.count(t.id)))).scalar_one()
|
||||||
|
for t in (Recipe, Measurement, User)
|
||||||
|
]
|
||||||
|
|
||||||
|
await seed_fiera.seed(False, False, FINE)
|
||||||
|
primo = await conta()
|
||||||
|
await seed_fiera.seed(False, False, FINE)
|
||||||
|
assert await conta() == primo # utenti aggiornati, ricette e misure non duplicate
|
||||||
|
|
||||||
|
await seed_fiera.seed(True, False, FINE)
|
||||||
|
assert await conta() == primo # stessi numeri: il seme e' fisso
|
||||||
|
|
||||||
|
async def test_dry_run_non_salva(self, seed_fiera, db_session):
|
||||||
|
await seed_fiera.seed(False, True, FINE)
|
||||||
|
assert (await db_session.execute(select(func.count(User.id)))).scalar_one() == 0
|
||||||
|
assert (await db_session.execute(select(func.count(Measurement.id)))).scalar_one() == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_file_sqlite_con_alembic(seed_fiera, tmp_path, monkeypatch):
|
||||||
|
"""La strada vera: schema da `alembic upgrade head`, poi il seed con --db."""
|
||||||
|
db = tmp_path / "tmf.db"
|
||||||
|
url = f"sqlite+aiosqlite:///{db.as_posix()}"
|
||||||
|
monkeypatch.setattr(Settings, "database_url", property(lambda self: url))
|
||||||
|
# alembic fa asyncio.run(): fuori dal loop di pytest-asyncio, in un thread
|
||||||
|
import asyncio
|
||||||
|
await asyncio.to_thread(command.upgrade, Config(str(_ALEMBIC_INI)), "head")
|
||||||
|
|
||||||
|
argomenti = Namespace(db=db, replace=False, dry_run=False, fine=FINE.isoformat())
|
||||||
|
assert await seed_fiera._main_async(argomenti) == 0
|
||||||
|
|
||||||
|
con = sqlite3.connect(db)
|
||||||
|
try:
|
||||||
|
assert con.execute("SELECT COUNT(*) FROM users WHERE is_admin=1").fetchone()[0] == 1
|
||||||
|
assert con.execute("SELECT COUNT(*) FROM recipes").fetchone()[0] == 3
|
||||||
|
assert con.execute("SELECT COUNT(*) FROM measurements").fetchone()[0] > 1000
|
||||||
|
finally:
|
||||||
|
con.close()
|
||||||
@@ -0,0 +1,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()
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
"""Backend su un file SQLite locale (pacchetto Windows, TieMeasureFlow#77).
|
||||||
|
|
||||||
|
Il modulo `database` costruisce l'engine all'import leggendo l'ambiente, quindi
|
||||||
|
i test che riguardano DB_URL girano in un sottoprocesso con DB_URL/UPLOAD_DIR
|
||||||
|
impostati: e' lo stesso percorso di avvio.py nel pacchetto, senza toccare
|
||||||
|
l'engine in memoria usato dal resto della suite.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import textwrap
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from src.backend.config import Settings
|
||||||
|
|
||||||
|
_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
|
||||||
|
# Script eseguito nel sottoprocesso: upgrade head da DB vuoto, poi app reale
|
||||||
|
# (health, login con utente creato via ORM), PRAGMA e vincolo FK.
|
||||||
|
_SCRIPT = textwrap.dedent(
|
||||||
|
"""
|
||||||
|
import asyncio, json, sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
# WeasyPrint senza GTK: stesso mock di conftest.py (le DLL sono affare del piano B)
|
||||||
|
sys.modules.setdefault("weasyprint", MagicMock())
|
||||||
|
from alembic import command
|
||||||
|
from alembic.config import Config
|
||||||
|
|
||||||
|
from src.backend.config import settings
|
||||||
|
ini = Path(r"{ini}")
|
||||||
|
command.upgrade(Config(str(ini)), "head")
|
||||||
|
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from sqlalchemy import select, text
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from src.backend.database import async_session_factory, engine
|
||||||
|
from src.backend.main import app
|
||||||
|
from src.backend.models.orm.user import User
|
||||||
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
|
from src.backend.services.auth_service import hash_password
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
out = {{}}
|
||||||
|
async with async_session_factory() as s:
|
||||||
|
s.add(User(username="fiera", password_hash=hash_password("Pass12345"),
|
||||||
|
display_name="Fiera", roles=["Maker"], is_admin=True))
|
||||||
|
await s.commit()
|
||||||
|
async with async_session_factory() as s:
|
||||||
|
out["utente"] = (await s.execute(select(User.username))).scalars().all()
|
||||||
|
out["fk"] = (await s.execute(text("PRAGMA foreign_keys"))).scalar()
|
||||||
|
out["journal"] = (await s.execute(text("PRAGMA journal_mode"))).scalar()
|
||||||
|
out["busy"] = (await s.execute(text("PRAGMA busy_timeout"))).scalar()
|
||||||
|
# FK attive: una versione con recipe_id inesistente deve essere rifiutata
|
||||||
|
try:
|
||||||
|
async with async_session_factory() as s:
|
||||||
|
s.add(RecipeVersion(recipe_id=99999, version_number=1, created_by=1))
|
||||||
|
await s.commit()
|
||||||
|
out["fk_violation"] = False
|
||||||
|
except IntegrityError:
|
||||||
|
out["fk_violation"] = True
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c:
|
||||||
|
out["health"] = (await c.get("/api/health")).status_code
|
||||||
|
r = await c.post("/api/auth/login", json={{"username": "fiera", "password": "Pass12345"}})
|
||||||
|
out["login"] = r.status_code
|
||||||
|
out["upload_path"] = str(settings.upload_path)
|
||||||
|
out["database_url"] = settings.database_url
|
||||||
|
await engine.dispose()
|
||||||
|
print("RISULTATO=" + json.dumps(out))
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run(tmp_path: Path, extra_env: dict[str, str]) -> dict:
|
||||||
|
ini = _ROOT / "src" / "backend" / "migrations" / "alembic.ini"
|
||||||
|
env = {k: v for k, v in os.environ.items() if k not in ("DB_URL", "UPLOAD_DIR")}
|
||||||
|
env.update(extra_env)
|
||||||
|
env["PYTHONPATH"] = str(_ROOT)
|
||||||
|
env["PYTHONIOENCODING"] = "utf-8"
|
||||||
|
proc = subprocess.run(
|
||||||
|
[sys.executable, "-c", _SCRIPT.format(ini=str(ini))],
|
||||||
|
cwd=str(_ROOT), env=env, capture_output=True, text=True, timeout=180,
|
||||||
|
)
|
||||||
|
assert proc.returncode == 0, proc.stderr[-3000:]
|
||||||
|
line = [l for l in proc.stdout.splitlines() if l.startswith("RISULTATO=")][-1]
|
||||||
|
return json.loads(line[len("RISULTATO="):])
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_sqlite_upgrade_app_pragma_e_upload_assoluto(tmp_path):
|
||||||
|
db = tmp_path / "tmf.db"
|
||||||
|
uploads = tmp_path / "uploads"
|
||||||
|
# barre "/" nell'URL come nel pacchetto Windows (D:/... non va in escape)
|
||||||
|
url = f"sqlite+aiosqlite:///{db.as_posix()}"
|
||||||
|
out = _run(tmp_path, {"DB_URL": url, "UPLOAD_DIR": str(uploads)})
|
||||||
|
|
||||||
|
assert out["database_url"] == url
|
||||||
|
assert db.exists() # il file e' stato creato da upgrade head
|
||||||
|
assert out["utente"] == ["fiera"]
|
||||||
|
assert out["health"] == 200
|
||||||
|
assert out["login"] == 200
|
||||||
|
assert out["fk"] == 1 # PRAGMA foreign_keys attivo
|
||||||
|
assert out["fk_violation"] is True
|
||||||
|
assert out["journal"] == "wal"
|
||||||
|
assert out["busy"] == 5000
|
||||||
|
# UPLOAD_DIR assoluto rispettato (non accodato alla radice del progetto)
|
||||||
|
assert Path(out["upload_path"]) == uploads
|
||||||
|
|
||||||
|
|
||||||
|
def test_database_url_senza_db_url_resta_mysql(monkeypatch):
|
||||||
|
monkeypatch.delenv("DB_URL", raising=False)
|
||||||
|
s = Settings(_env_file=None, db_url=None)
|
||||||
|
assert s.database_url.startswith("mysql+asyncmy://")
|
||||||
|
|
||||||
|
|
||||||
|
def test_db_url_vince_sulle_parti_db(monkeypatch):
|
||||||
|
s = Settings(_env_file=None, db_url="sqlite+aiosqlite:///C:/x/tmf.db", db_host="altro")
|
||||||
|
assert s.database_url == "sqlite+aiosqlite:///C:/x/tmf.db"
|
||||||
|
|
||||||
|
|
||||||
|
def test_produzione_con_sqlite_non_richiede_db_password():
|
||||||
|
# niente RuntimeError: con SQLite la password DB di default non conta
|
||||||
|
Settings(
|
||||||
|
_env_file=None, app_env="production", server_secret_key="x" * 40,
|
||||||
|
db_url="sqlite+aiosqlite:///C:/x/tmf.db",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_produzione_mysql_rifiuta_ancora_password_di_default():
|
||||||
|
import pytest
|
||||||
|
with pytest.raises(Exception, match="DB_PASSWORD"):
|
||||||
|
Settings(_env_file=None, app_env="production", server_secret_key="x" * 40)
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_dir_relativo_resta_sotto_la_radice():
|
||||||
|
s = Settings(_env_file=None, upload_dir="uploads")
|
||||||
|
assert s.upload_path == _ROOT / "uploads"
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_dir_assoluto(tmp_path):
|
||||||
|
s = Settings(_env_file=None, upload_dir=str(tmp_path / "u"))
|
||||||
|
assert s.upload_path == tmp_path / "u"
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
"""#83: ricetta DXF compare da camera e calibrazione letta dall'acquisitore.
|
||||||
|
|
||||||
|
Il worker (W2) e l'acquisitore (C1) non girano nei test: si fingono, come
|
||||||
|
fanno gli altri test di visione. La calibrazione vive nell'acquisitore esterno,
|
||||||
|
TMF la legge via GET {camera_url}/camera/calibration.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from src.backend.config import settings
|
||||||
|
from src.backend.models.orm.station import Station
|
||||||
|
from src.backend.models.orm.task import RecipeSubtask
|
||||||
|
from src.backend.models.orm.vision import VisionResult
|
||||||
|
from src.backend.services import vision_service
|
||||||
|
from src.backend.tests.conftest import auth_headers
|
||||||
|
from src.backend.tests.test_vision_recipe_upload import an_image, make_task
|
||||||
|
from src.backend.tests.test_vision_station_worker import ( # noqa: F401 (fixture worker_calls)
|
||||||
|
_camera_task, _execute, _station_with_run, worker_calls,
|
||||||
|
)
|
||||||
|
|
||||||
|
CAMERA_URL = "http://10.0.0.9:8200"
|
||||||
|
CAL = {
|
||||||
|
"source": "camera:A1", "px_per_mm": 12.5, "mm_per_px": 0.08, "rms_px": 0.2,
|
||||||
|
"n_corners": 40, "cols": 8, "rows": 5, "square_mm": 5.0, "image_width": 640,
|
||||||
|
"image_height": 480, "scale_spread_pct": 0.1, "calibrated_at": "2026-10-23T10:00:00",
|
||||||
|
}
|
||||||
|
GRAPH = {
|
||||||
|
"schema_version": 2, "name": "dxf-compare", "tools": [],
|
||||||
|
"meta": {"dxf": {"disegno": {"path": "disegno.dxf"}}, "calibration": {"path": "calibrazione.json"}},
|
||||||
|
}
|
||||||
|
OUTPUTS = [{"key": "dxf.points_in_pct", "quantity": "ratio", "unit": "%"}]
|
||||||
|
|
||||||
|
|
||||||
|
def _b64(data: bytes) -> str:
|
||||||
|
return base64.b64encode(data).decode()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def redirect_uploads(monkeypatch, tmp_path):
|
||||||
|
# upload_path = parents[2] / upload_dir: un assoluto sostituisce la base
|
||||||
|
monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def w2(monkeypatch):
|
||||||
|
"""Finto worker W2: ricorda le chiamate e risponde col corpo del contratto."""
|
||||||
|
calls: list[dict] = []
|
||||||
|
|
||||||
|
async def fake(image_bytes, dxf_bytes, **kwargs):
|
||||||
|
calls.append({"dxf": dxf_bytes, **kwargs})
|
||||||
|
return {
|
||||||
|
"graph": GRAPH,
|
||||||
|
"files": {
|
||||||
|
"disegno.dxf": _b64(dxf_bytes),
|
||||||
|
"calibrazione.json": _b64(json.dumps({"px_per_mm": kwargs["px_per_mm"]}).encode()),
|
||||||
|
},
|
||||||
|
"outputs": {"dxf.points_in_pct": 99.0},
|
||||||
|
"overlay": [], "outcome": "pass",
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.backend.services.vision_service.dxf_compare_recipe", fake)
|
||||||
|
return calls
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def validate_ok(monkeypatch):
|
||||||
|
async def fake(graph, recipe_dir):
|
||||||
|
return {"ok": True, "errors": [], "outputs": OUTPUTS}
|
||||||
|
monkeypatch.setattr("src.backend.services.vision_service.validate_recipe", fake)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def acquisitore(monkeypatch):
|
||||||
|
"""Finto C1: `state['cal']` = dict (200), None (404), 'down' (irraggiungibile)."""
|
||||||
|
state: dict = {"cal": CAL, "urls": []}
|
||||||
|
|
||||||
|
class _Resp:
|
||||||
|
def __init__(self, code, body=None):
|
||||||
|
self.status_code, self._body, self.text = code, body, ""
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return self._body
|
||||||
|
|
||||||
|
class _Client:
|
||||||
|
def __init__(self, *a, **k):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *exc):
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def get(self, url, params=None):
|
||||||
|
state["urls"].append((url, params))
|
||||||
|
if state["cal"] == "down":
|
||||||
|
raise httpx.ConnectError("giu'")
|
||||||
|
if state["cal"] is None:
|
||||||
|
return _Resp(404)
|
||||||
|
return _Resp(200, state["cal"])
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx, "AsyncClient", _Client)
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
async def _station(db_session, user, *, camera=True, worker_url=None, code="ST-C"):
|
||||||
|
station = Station(
|
||||||
|
code=code, name="Stazione C", active=True, created_by=user.id,
|
||||||
|
vision_worker_url=worker_url,
|
||||||
|
camera_url=CAMERA_URL if camera else None,
|
||||||
|
camera_source="camera:A1" if camera else None,
|
||||||
|
)
|
||||||
|
db_session.add(station)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(station)
|
||||||
|
return station
|
||||||
|
|
||||||
|
|
||||||
|
def _post(client, user, task, *, save=False, **data):
|
||||||
|
return client.post(
|
||||||
|
f"/api/vision/tasks/{task.id}/recipe/dxf-compare",
|
||||||
|
headers=auth_headers(user),
|
||||||
|
files={
|
||||||
|
"image": ("p.png", an_image(), "image/png"),
|
||||||
|
"dxf": ("d.dxf", b"0\nEOF", "application/octet-stream"),
|
||||||
|
},
|
||||||
|
data={"save": str(save).lower(), **{k: str(v) for k, v in data.items() if v is not None}},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- lettura della calibrazione dall'acquisitore (C1) -------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_calibration_ok(db_session, maker_user, acquisitore):
|
||||||
|
station = await _station(db_session, maker_user)
|
||||||
|
cal, reason = await vision_service.fetch_station_calibration(station)
|
||||||
|
assert (cal, reason) == (CAL, "ok")
|
||||||
|
assert acquisitore["urls"] == [(f"{CAMERA_URL}/camera/calibration", {"source": "camera:A1"})]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_calibration_reasons(db_session, maker_user, acquisitore):
|
||||||
|
station = await _station(db_session, maker_user)
|
||||||
|
acquisitore["cal"] = None
|
||||||
|
assert await vision_service.fetch_station_calibration(station) == (None, "not_calibrated")
|
||||||
|
acquisitore["cal"] = "down"
|
||||||
|
assert await vision_service.fetch_station_calibration(station) == (None, "unreachable")
|
||||||
|
assert await vision_service.get_station_calibration(station) is None
|
||||||
|
acquisitore["cal"] = {**CAL, "px_per_mm": 0} # valore inutilizzabile = non calibrata
|
||||||
|
assert await vision_service.fetch_station_calibration(station) == (None, "not_calibrated")
|
||||||
|
no_camera = await _station(db_session, maker_user, camera=False, code="ST-N")
|
||||||
|
assert await vision_service.fetch_station_calibration(no_camera) == (None, "no_camera")
|
||||||
|
assert await vision_service.fetch_station_calibration(None) == (None, "no_camera")
|
||||||
|
|
||||||
|
|
||||||
|
# --- B3: POST /api/vision/tasks/{id}/recipe/dxf-compare -----------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_compare_preview_writes_nothing(
|
||||||
|
client, db_session, maker_user, w2, acquisitore,
|
||||||
|
):
|
||||||
|
task, subtask = await make_task(db_session, maker_user)
|
||||||
|
station = await _station(db_session, maker_user, worker_url="http://10.0.0.7:8100")
|
||||||
|
|
||||||
|
resp = await _post(client, maker_user, task, station_id=station.id, band_mm=0.2,
|
||||||
|
min_points_pct=90, polarity="light")
|
||||||
|
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["saved"] is False and body["recipe_dir"] is None and body["outputs"] is None
|
||||||
|
assert "files" not in body["result"] and body["result"]["graph"] == GRAPH
|
||||||
|
# px_per_mm dall'acquisitore, parametri e worker della stazione
|
||||||
|
assert w2[0]["px_per_mm"] == 12.5 and w2[0]["band_mm"] == 0.2
|
||||||
|
assert w2[0]["min_points_pct"] == 90 and w2[0]["polarity"] == "light"
|
||||||
|
assert w2[0]["worker_url"] == "http://10.0.0.7:8100"
|
||||||
|
await db_session.refresh(task)
|
||||||
|
assert task.vision_json is None and task.vision_recipe_dir is None
|
||||||
|
quotes = (await db_session.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id))).scalars().all()
|
||||||
|
assert [q.id for q in quotes] == [subtask.id] # nessuna quota nuova
|
||||||
|
assert not (settings.upload_path / "vision" / "recipes").exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_compare_save_stores_recipe_and_quote_once(
|
||||||
|
client, db_session, maker_user, w2, validate_ok,
|
||||||
|
):
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
|
||||||
|
resp = await _post(client, maker_user, task, save=True, px_per_mm=10, min_points_pct=95)
|
||||||
|
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["saved"] is True and body["outputs"] == OUTPUTS
|
||||||
|
assert "files" not in body["result"]
|
||||||
|
folder = settings.upload_path / body["recipe_dir"]
|
||||||
|
assert (folder / "disegno.dxf").read_bytes() == b"0\nEOF"
|
||||||
|
assert json.loads((folder / "calibrazione.json").read_text()) == {"px_per_mm": 10.0}
|
||||||
|
await db_session.refresh(task)
|
||||||
|
assert task.vision_json == GRAPH and task.vision_recipe_dir == body["recipe_dir"]
|
||||||
|
|
||||||
|
quotes = (await db_session.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.vision_output == "dxf.points_in_pct")
|
||||||
|
.where(RecipeSubtask.task_id == task.id))).scalars().all()
|
||||||
|
assert len(quotes) == 1
|
||||||
|
q = quotes[0]
|
||||||
|
assert q.description == "Punti in tolleranza"
|
||||||
|
assert (float(q.nominal), float(q.ltl), float(q.utl)) == (100.0, 95.0, 100.0)
|
||||||
|
|
||||||
|
# secondo salvataggio: la quota non si duplica
|
||||||
|
task_id = task.id # dopo expire_all l'attributo scaduto non si rilegge in async
|
||||||
|
again = await _post(client, maker_user, task, save=True, px_per_mm=10)
|
||||||
|
assert again.status_code == 200
|
||||||
|
db_session.expire_all()
|
||||||
|
count = (await db_session.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.vision_output.is_not(None))
|
||||||
|
.where(RecipeSubtask.task_id == task_id))).scalars().all()
|
||||||
|
assert len(count) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_compare_keeps_an_existing_vision_quote(
|
||||||
|
client, db_session, maker_user, w2, validate_ok,
|
||||||
|
):
|
||||||
|
task, subtask = await make_task(db_session, maker_user)
|
||||||
|
subtask.vision_output = "t1.diameter"
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
resp = await _post(client, maker_user, task, save=True, px_per_mm=10)
|
||||||
|
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
quotes = (await db_session.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id))).scalars().all()
|
||||||
|
assert len(quotes) == 1 # niente quota in piu'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_compare_uncalibrated_station_is_422(
|
||||||
|
client, db_session, maker_user, w2, acquisitore,
|
||||||
|
):
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
station = await _station(db_session, maker_user)
|
||||||
|
acquisitore["cal"] = None
|
||||||
|
resp = await _post(client, maker_user, task, station_id=station.id)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"] == ["stazione non calibrata"]
|
||||||
|
assert w2 == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_compare_unreachable_acquirer_is_422_with_its_own_message(
|
||||||
|
client, db_session, maker_user, w2, acquisitore,
|
||||||
|
):
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
station = await _station(db_session, maker_user)
|
||||||
|
acquisitore["cal"] = "down"
|
||||||
|
resp = await _post(client, maker_user, task, station_id=station.id)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"] == ["acquisitore della stazione non raggiungibile"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_compare_without_station_nor_scale_is_422(client, db_session, maker_user, w2):
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
resp = await _post(client, maker_user, task)
|
||||||
|
assert resp.status_code == 422 and resp.json()["detail"] == ["stazione non calibrata"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_compare_needs_maker(client, db_session, maker_user, measurement_tec_user, w2):
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
resp = await _post(client, measurement_tec_user, task, px_per_mm=10)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_compare_worker_422_is_forwarded(
|
||||||
|
client, db_session, maker_user, monkeypatch,
|
||||||
|
):
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
async def refuse(*a, **k):
|
||||||
|
raise HTTPException(status_code=422, detail="pezzo non trovato")
|
||||||
|
monkeypatch.setattr("src.backend.services.vision_service.dxf_compare_recipe", refuse)
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
resp = await _post(client, maker_user, task, px_per_mm=10)
|
||||||
|
assert resp.status_code == 422 and resp.json()["detail"] == "pezzo non trovato"
|
||||||
|
|
||||||
|
|
||||||
|
# --- execute: calibrazione della stazione -------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_passes_station_calibration_and_snapshots_it(
|
||||||
|
client, db_session, measurement_tec_user, worker_calls, acquisitore,
|
||||||
|
):
|
||||||
|
recipe, task = await _camera_task(db_session, measurement_tec_user.id)
|
||||||
|
station, run = await _station_with_run(db_session, measurement_tec_user, recipe, None)
|
||||||
|
station.camera_url, station.camera_source = CAMERA_URL, "camera:A1"
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
await _execute(client, measurement_tec_user, task, run)
|
||||||
|
|
||||||
|
assert worker_calls[0]["calibration"] == {"px_per_mm": 12.5}
|
||||||
|
result = (await db_session.execute(select(VisionResult))).scalars().one()
|
||||||
|
assert result.calibration_snapshot == CAL
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_without_calibration_is_unchanged(
|
||||||
|
client, db_session, measurement_tec_user, worker_calls, acquisitore,
|
||||||
|
):
|
||||||
|
recipe, task = await _camera_task(db_session, measurement_tec_user.id)
|
||||||
|
station, run = await _station_with_run(db_session, measurement_tec_user, recipe, None)
|
||||||
|
station.camera_url, station.camera_source = CAMERA_URL, "camera:A1"
|
||||||
|
await db_session.commit()
|
||||||
|
acquisitore["cal"] = "down" # acquisitore giu': si misura come prima, senza calibrazione
|
||||||
|
|
||||||
|
await _execute(client, measurement_tec_user, task, run)
|
||||||
|
|
||||||
|
assert "calibration" not in worker_calls[0]
|
||||||
|
result = (await db_session.execute(select(VisionResult))).scalars().one()
|
||||||
|
assert result.calibration_snapshot is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_station_without_camera_never_asks(
|
||||||
|
client, db_session, measurement_tec_user, worker_calls, acquisitore,
|
||||||
|
):
|
||||||
|
recipe, task = await _camera_task(db_session, measurement_tec_user.id)
|
||||||
|
_, run = await _station_with_run(db_session, measurement_tec_user, recipe, None)
|
||||||
|
|
||||||
|
await _execute(client, measurement_tec_user, task, run)
|
||||||
|
|
||||||
|
assert acquisitore["urls"] == [] and "calibration" not in worker_calls[0]
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
"""Admin blueprint - user management."""
|
"""Admin blueprint - user management."""
|
||||||
|
from urllib.parse import urlparse # #88: validazione url camera
|
||||||
|
import requests as http_requests # #88: lettura sorgenti dall'acquisitore (come maker.py)
|
||||||
from flask import Blueprint, flash, jsonify, redirect, render_template, request, session, url_for
|
from flask import Blueprint, flash, jsonify, redirect, render_template, request, session, url_for
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
|
|
||||||
@@ -170,6 +172,39 @@ def api_toggle_active(user_id: int):
|
|||||||
|
|
||||||
# --- Stations ---
|
# --- Stations ---
|
||||||
|
|
||||||
|
@admin_bp.route("/api/camera/sources", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def api_camera_sources():
|
||||||
|
"""Proxy (#88): elenco sorgenti di un acquisitore, GET <url>/camera/sources."""
|
||||||
|
url = (request.args.get("url") or "").strip()
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
||||||
|
return jsonify({"error": True, "detail": _("Indirizzo della camera non valido")}), 400
|
||||||
|
try:
|
||||||
|
resp = http_requests.get(f"{url.rstrip('/')}/camera/sources", timeout=5)
|
||||||
|
except http_requests.RequestException as exc:
|
||||||
|
return jsonify({
|
||||||
|
"error": True,
|
||||||
|
"detail": _("Servizio camera non raggiungibile: %(detail)s", detail=str(exc)),
|
||||||
|
}), 502
|
||||||
|
invalid = (jsonify({"error": True, "detail": _("Risposta non valida dal servizio camera")}), 502)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return invalid
|
||||||
|
try:
|
||||||
|
sources = resp.json().get("sources")
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
return invalid
|
||||||
|
if not isinstance(sources, list):
|
||||||
|
return invalid
|
||||||
|
# solo i tre campi che la UI usa, sempre stringhe
|
||||||
|
clean = [
|
||||||
|
{k: str(s.get(k, "")) for k in ("name", "label", "kind")}
|
||||||
|
for s in sources if isinstance(s, dict)
|
||||||
|
]
|
||||||
|
return jsonify({"sources": clean}), 200
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.route("/api/stations", methods=["POST"])
|
@admin_bp.route("/api/stations", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import math
|
|||||||
|
|
||||||
import requests as http_requests
|
import requests as http_requests
|
||||||
|
|
||||||
from flask import Blueprint, flash, jsonify, redirect, render_template, request, session, url_for
|
from functools import wraps
|
||||||
|
|
||||||
|
from flask import Blueprint, Response, abort, flash, jsonify, redirect, render_template, request, session, url_for
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
|
|
||||||
from blueprints.auth import login_required, role_required
|
from blueprints.auth import login_required, role_required
|
||||||
@@ -483,6 +485,173 @@ def api_upload_vision_recipe(task_id: int):
|
|||||||
return jsonify(resp), 200
|
return jsonify(resp), 200
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# RICETTA DXF DA CAMERA (#83): stazioni, camera, calibrazione, creazione ricetta
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def maker_or_admin_required(f):
|
||||||
|
"""Maker dell'editor oppure admin della pagina stazioni (#83: stesse rotte camera)."""
|
||||||
|
@wraps(f)
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
user = session.get("user", {})
|
||||||
|
if not (user.get("is_admin") or "Maker" in user.get("roles", [])):
|
||||||
|
abort(403)
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated
|
||||||
|
|
||||||
|
|
||||||
|
def _stations_for_maker() -> list[dict]:
|
||||||
|
"""Stazioni visibili al chiamante.
|
||||||
|
|
||||||
|
GET /api/stations e' solo admin: un Maker non admin ripiega sulla stazione
|
||||||
|
corrente (by-code, aperta a tutti), cosi' l'editor funziona comunque.
|
||||||
|
"""
|
||||||
|
resp = api_client.get("/api/stations")
|
||||||
|
if isinstance(resp, list):
|
||||||
|
return resp
|
||||||
|
if Config.STATION_CODE:
|
||||||
|
one = api_client.get_station_by_code(Config.STATION_CODE)
|
||||||
|
if isinstance(one, dict) and not one.get("error"):
|
||||||
|
return [one]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _camera_of_station(station_id: int) -> tuple[str, str] | None:
|
||||||
|
"""(camera_url, camera_source) della stazione con questo id, None se senza camera."""
|
||||||
|
for st in _stations_for_maker():
|
||||||
|
if st.get("id") == station_id:
|
||||||
|
if st.get("camera_url") and st.get("camera_source"):
|
||||||
|
return str(st["camera_url"]), str(st["camera_source"])
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@maker_bp.route("/api/stations", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
@maker_or_admin_required
|
||||||
|
def api_list_stations_for_maker():
|
||||||
|
"""Elenco stazioni con camera per il pannello «Crea ricetta da DXF»."""
|
||||||
|
out = []
|
||||||
|
for st in _stations_for_maker():
|
||||||
|
if st.get("camera_url") and st.get("camera_source"):
|
||||||
|
out.append({
|
||||||
|
"id": st.get("id"), "code": st.get("code"), "name": st.get("name"),
|
||||||
|
"camera_url": st.get("camera_url"),
|
||||||
|
})
|
||||||
|
return jsonify(out), 200
|
||||||
|
|
||||||
|
|
||||||
|
# Copia minima della logica di measure.api_vision_grab, ma per una stazione scelta per id
|
||||||
|
@maker_bp.route("/api/stations/<int:station_id>/camera/grab", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
@maker_or_admin_required
|
||||||
|
def api_station_camera_grab(station_id: int):
|
||||||
|
"""Proxy: un frame dalla camera della stazione, risposta PNG."""
|
||||||
|
camera = _camera_of_station(station_id)
|
||||||
|
if camera is None:
|
||||||
|
return jsonify({
|
||||||
|
"error": True,
|
||||||
|
"detail": _("Camera non configurata per questa stazione"),
|
||||||
|
}), 409
|
||||||
|
camera_url, camera_source = camera
|
||||||
|
try:
|
||||||
|
resp = http_requests.post(
|
||||||
|
f"{camera_url.rstrip('/')}/camera/grab",
|
||||||
|
json={"source": camera_source, "trigger": "continuo", "timeout_ms": 5000},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
except http_requests.RequestException as exc:
|
||||||
|
return jsonify({
|
||||||
|
"error": True,
|
||||||
|
"detail": _("Servizio camera non raggiungibile: %(detail)s", detail=str(exc)),
|
||||||
|
}), 502
|
||||||
|
if resp.status_code != 200:
|
||||||
|
detail = ""
|
||||||
|
try:
|
||||||
|
body = resp.json()
|
||||||
|
detail = body.get("detail", "") if isinstance(body, dict) else ""
|
||||||
|
except ValueError:
|
||||||
|
detail = (resp.text or "")[:200]
|
||||||
|
return jsonify({
|
||||||
|
"error": True,
|
||||||
|
"detail": _("Acquisizione non riuscita (%(status)s): %(detail)s",
|
||||||
|
status=resp.status_code, detail=detail),
|
||||||
|
}), 502
|
||||||
|
return Response(resp.content, content_type="image/png")
|
||||||
|
|
||||||
|
|
||||||
|
@maker_bp.route("/api/stations/<int:station_id>/camera/calibration", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
@maker_or_admin_required
|
||||||
|
def api_station_camera_calibration(station_id: int):
|
||||||
|
"""Proxy: calibrazione della camera letta dall'acquisitore (C1).
|
||||||
|
|
||||||
|
Risponde sempre 200 con `state`: calibrated | uncalibrated | unreachable,
|
||||||
|
cosi' la UI mostra lo stato senza trattare «non calibrata» come errore.
|
||||||
|
409 solo se la stazione non ha camera.
|
||||||
|
"""
|
||||||
|
camera = _camera_of_station(station_id)
|
||||||
|
if camera is None:
|
||||||
|
return jsonify({
|
||||||
|
"error": True,
|
||||||
|
"detail": _("Camera non configurata per questa stazione"),
|
||||||
|
}), 409
|
||||||
|
camera_url, camera_source = camera
|
||||||
|
base = camera_url.rstrip("/")
|
||||||
|
link = f"{base}/acquisizione" # pagina di acquisizione dell'acquisitore (#88; era /calibrazione, C4)
|
||||||
|
try:
|
||||||
|
resp = http_requests.get(
|
||||||
|
f"{base}/camera/calibration",
|
||||||
|
params={"source": camera_source},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
except http_requests.RequestException as exc:
|
||||||
|
return jsonify({"state": "unreachable", "detail": str(exc), "calibration_url": link}), 200
|
||||||
|
if resp.status_code == 404:
|
||||||
|
return jsonify({"state": "uncalibrated", "calibration_url": link}), 200
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return jsonify({
|
||||||
|
"state": "unreachable", "calibration_url": link,
|
||||||
|
"detail": _("Risposta %(status)s dall'acquisitore", status=resp.status_code),
|
||||||
|
}), 200
|
||||||
|
try:
|
||||||
|
calibration = resp.json()
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({"state": "unreachable", "calibration_url": link,
|
||||||
|
"detail": _("Risposta non valida dall'acquisitore")}), 200
|
||||||
|
return jsonify({"state": "calibrated", "calibration": calibration, "calibration_url": link}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@maker_bp.route("/api/vision/tasks/<int:task_id>/recipe/dxf-compare", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@role_required("Maker")
|
||||||
|
def api_dxf_compare_recipe(task_id: int):
|
||||||
|
"""Proxy: crea (o prova, save=false) la ricetta DXF compare da camera (B3)."""
|
||||||
|
image = request.files.get("image")
|
||||||
|
dxf = request.files.get("dxf")
|
||||||
|
if image is None or not image.filename or dxf is None or not dxf.filename:
|
||||||
|
return jsonify({"error": True, "detail": _("Servono l'immagine e il file DXF")}), 400
|
||||||
|
|
||||||
|
# Solo i campi del contratto B3; i vuoti non si inoltrano (il backend ha i default)
|
||||||
|
data = {}
|
||||||
|
for key in ("station_id", "px_per_mm", "band_mm", "min_points_pct", "polarity", "save"):
|
||||||
|
value = request.form.get(key)
|
||||||
|
if value not in (None, ""):
|
||||||
|
data[key] = value
|
||||||
|
|
||||||
|
resp = api_client.post(
|
||||||
|
f"/api/vision/tasks/{task_id}/recipe/dxf-compare",
|
||||||
|
data=data,
|
||||||
|
files={
|
||||||
|
"image": (image.filename, image.read(), image.mimetype or "application/octet-stream"),
|
||||||
|
"dxf": (dxf.filename, dxf.read(), dxf.mimetype or "application/octet-stream"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if resp.get("error"):
|
||||||
|
return jsonify(resp), resp.get("status_code", 500)
|
||||||
|
return jsonify(resp), 200
|
||||||
|
|
||||||
|
|
||||||
@maker_bp.route("/api/files/<path:file_path>", methods=["GET"])
|
@maker_bp.route("/api/files/<path:file_path>", methods=["GET"])
|
||||||
@login_required
|
@login_required
|
||||||
def api_get_file(file_path: str):
|
def api_get_file(file_path: str):
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
// «Crea ricetta da DXF» dell'editor task (#83): da un'immagine della camera e un DXF
|
||||||
|
// genera la ricetta dxf_compare (POST /maker/api/vision/tasks/<id>/recipe/dxf-compare).
|
||||||
|
// «Prova» (save=false) mostra overlay e quattro valori, «Salva ricetta» (save=true)
|
||||||
|
// la scrive e avvisa il componente visionRecipe con l'evento 'dxf-recipe-saved'.
|
||||||
|
// Uso: x-data="dxfRecipeWizard(task.id, csrfFn, msgs)". Dipende da Alpine.js e station_calibration.js.
|
||||||
|
function dxfRecipeWizard(taskId, csrf, msgs) {
|
||||||
|
// Colori overlay per ruolo, uguali a vision_execute.js (reference, roi, found)
|
||||||
|
const ROLE_COLORS = { reference: '#2563eb', found: '#16a34a', roi: '#f59e0b' };
|
||||||
|
const VALUE_KEYS = ['dxf.points_in_pct', 'dxf.distance_avg', 'dxf.distance_min', 'dxf.distance_max'];
|
||||||
|
|
||||||
|
return {
|
||||||
|
taskId: taskId,
|
||||||
|
msgs: msgs || {},
|
||||||
|
open: false,
|
||||||
|
stations: [],
|
||||||
|
stationId: '',
|
||||||
|
calState: '', // '' | calibrated | uncalibrated | unreachable
|
||||||
|
calLabel: '',
|
||||||
|
calUrl: '',
|
||||||
|
imageFile: null, // immagine caricata o acquisita
|
||||||
|
imageName: '',
|
||||||
|
imageSrc: '',
|
||||||
|
dxfFile: null,
|
||||||
|
bandMm: 0.1,
|
||||||
|
minPointsPct: 95,
|
||||||
|
dark: true, // spuntato = pezzo scuro su fondo chiaro (controluce)
|
||||||
|
busy: false,
|
||||||
|
grabbing: false,
|
||||||
|
error: '',
|
||||||
|
notice: '',
|
||||||
|
result: null, // corpo B3 {result, saved, recipe_dir, outputs}
|
||||||
|
|
||||||
|
async toggle() {
|
||||||
|
this.open = !this.open;
|
||||||
|
if (this.open && !this.stations.length) await this.loadStations();
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadStations() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/maker/api/stations');
|
||||||
|
if (resp.ok) this.stations = await resp.json();
|
||||||
|
} catch (e) { console.error('dxfRecipeWizard stations', e); }
|
||||||
|
// Una sola stazione con camera: preselezionata
|
||||||
|
if (this.stations.length === 1) {
|
||||||
|
this.stationId = String(this.stations[0].id);
|
||||||
|
await this.onStation();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Cambio stazione: rilegge lo stato di calibrazione dall'acquisitore
|
||||||
|
async onStation() {
|
||||||
|
this.calState = ''; this.calLabel = ''; this.calUrl = '';
|
||||||
|
this.result = null;
|
||||||
|
if (!this.stationId) return;
|
||||||
|
const r = await fetchStationCalibration(this.stationId);
|
||||||
|
this.calState = r.state || 'unreachable';
|
||||||
|
this.calUrl = r.calibration_url || '';
|
||||||
|
if (this.calState === 'calibrated') this.calLabel = formatCalibration(r.calibration || {});
|
||||||
|
},
|
||||||
|
|
||||||
|
get calibrated() { return this.calState === 'calibrated'; },
|
||||||
|
get canRun() {
|
||||||
|
return !!(this.stationId && this.calibrated && this.imageFile && this.dxfFile) && !this.busy;
|
||||||
|
},
|
||||||
|
|
||||||
|
_setImage(file) {
|
||||||
|
if (this.imageSrc) URL.revokeObjectURL(this.imageSrc);
|
||||||
|
this.imageFile = file;
|
||||||
|
this.imageName = file.name || '';
|
||||||
|
this.imageSrc = URL.createObjectURL(file);
|
||||||
|
this.result = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
onImage(ev) {
|
||||||
|
const f = ev.target.files && ev.target.files[0];
|
||||||
|
if (f) this._setImage(f);
|
||||||
|
},
|
||||||
|
onDxf(ev) { this.dxfFile = (ev.target.files && ev.target.files[0]) || null; this.result = null; },
|
||||||
|
|
||||||
|
// Frame dalla camera della stazione scelta
|
||||||
|
async grab() {
|
||||||
|
if (!this.stationId || this.grabbing) return;
|
||||||
|
this.grabbing = true;
|
||||||
|
this.error = '';
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/maker/api/stations/${this.stationId}/camera/grab`, {
|
||||||
|
method: 'POST', headers: { 'X-CSRFToken': this._csrf() },
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const body = await resp.json().catch(() => ({}));
|
||||||
|
this.error = body.detail || this.msgs.requestFailed || 'Error';
|
||||||
|
} else {
|
||||||
|
const blob = await resp.blob();
|
||||||
|
this._setImage(new File([blob], 'camera.png', { type: blob.type || 'image/png' }));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.error = this.msgs.requestFailed || String(e);
|
||||||
|
}
|
||||||
|
this.grabbing = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
_csrf() { return typeof csrf === 'function' ? csrf() : csrf; },
|
||||||
|
|
||||||
|
// Il 422 porta `detail` (stringa o lista) oppure `errors`
|
||||||
|
_errorText(body) {
|
||||||
|
if (Array.isArray(body.errors) && body.errors.length) return body.errors.map(String).join('; ');
|
||||||
|
const d = body.detail;
|
||||||
|
if (Array.isArray(d)) return d.map(x => (typeof x === 'string' ? x : (x.msg || JSON.stringify(x)))).join('; ');
|
||||||
|
if (d && typeof d === 'object') return JSON.stringify(d);
|
||||||
|
return d || this.msgs.requestFailed || 'Error';
|
||||||
|
},
|
||||||
|
|
||||||
|
async run(save) {
|
||||||
|
if (!this.canRun) return;
|
||||||
|
this.busy = true;
|
||||||
|
this.error = '';
|
||||||
|
this.notice = '';
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('image', this.imageFile, this.imageFile.name || 'capture.png');
|
||||||
|
fd.append('dxf', this.dxfFile, this.dxfFile.name);
|
||||||
|
fd.append('station_id', this.stationId);
|
||||||
|
fd.append('band_mm', String(this.bandMm));
|
||||||
|
fd.append('min_points_pct', String(this.minPointsPct));
|
||||||
|
fd.append('polarity', this.dark ? 'dark' : 'light');
|
||||||
|
fd.append('save', save ? 'true' : 'false');
|
||||||
|
const resp = await fetch(`/maker/api/vision/tasks/${this.taskId}/recipe/dxf-compare`, {
|
||||||
|
method: 'POST', headers: { 'X-CSRFToken': this._csrf() }, body: fd,
|
||||||
|
});
|
||||||
|
const body = await resp.json().catch(() => ({}));
|
||||||
|
if (!resp.ok || body.error) {
|
||||||
|
this.error = this._errorText(body);
|
||||||
|
} else {
|
||||||
|
this.result = body;
|
||||||
|
this.$nextTick(() => this.drawOverlay());
|
||||||
|
if (save && body.saved) {
|
||||||
|
this.notice = this.msgs.saved || 'Saved';
|
||||||
|
// Il componente visionRecipe ricarica cartella, file e uscite delle quote
|
||||||
|
this.$dispatch('dxf-recipe-saved', { taskId: this.taskId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.error = this.msgs.requestFailed || String(e);
|
||||||
|
}
|
||||||
|
this.busy = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Le quattro uscite del confronto, nell'ordine fisso
|
||||||
|
get values() {
|
||||||
|
const out = (this.result && this.result.result && this.result.result.outputs) || {};
|
||||||
|
return VALUE_KEYS.map(k => ({ key: k, value: out[k] }));
|
||||||
|
},
|
||||||
|
|
||||||
|
fmt(v) {
|
||||||
|
return (typeof v === 'number') ? v.toLocaleString(document.documentElement.lang || 'it',
|
||||||
|
{ maximumFractionDigits: 3 }) : '-';
|
||||||
|
},
|
||||||
|
|
||||||
|
// Come vision_execute.js: canvas della dimensione dell'immagine, coordinate in pixel immagine
|
||||||
|
drawOverlay() {
|
||||||
|
const img = this.$refs.img, cv = this.$refs.overlay;
|
||||||
|
if (!img || !cv || !img.naturalWidth) return;
|
||||||
|
cv.width = img.naturalWidth;
|
||||||
|
cv.height = img.naturalHeight;
|
||||||
|
const g = cv.getContext('2d');
|
||||||
|
g.clearRect(0, 0, cv.width, cv.height);
|
||||||
|
g.lineWidth = Math.max(2, cv.width / 400);
|
||||||
|
g.lineJoin = 'round';
|
||||||
|
const shapes = (this.result && this.result.result && this.result.result.overlay) || [];
|
||||||
|
for (const item of shapes) {
|
||||||
|
const rows = item.rows || [], cols = item.cols || [];
|
||||||
|
if (!rows.length || rows.length !== cols.length) continue;
|
||||||
|
g.strokeStyle = ROLE_COLORS[item.role] || '#ef4444';
|
||||||
|
g.beginPath();
|
||||||
|
g.moveTo(cols[0], rows[0]);
|
||||||
|
for (let i = 1; i < rows.length; i++) g.lineTo(cols[i], rows[i]);
|
||||||
|
g.stroke();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// Stato di calibrazione della camera di una stazione (#83).
|
||||||
|
// La calibrazione vive nell'acquisitore (camera_service): qui si legge solo lo stato
|
||||||
|
// tramite il proxy Flask e si offre il link alla sua pagina «Calibra».
|
||||||
|
// Uso: x-data="stationCalibrationCell(station, msgs)" (msgs: stringhe tradotte). Dipende da Alpine.js.
|
||||||
|
|
||||||
|
// Formatta px/mm e data nella lingua della pagina (es. «20,03 px/mm · 23/10/2026»)
|
||||||
|
function formatCalibration(cal) {
|
||||||
|
const lang = document.documentElement.lang || 'it';
|
||||||
|
let text = new Intl.NumberFormat(lang, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||||
|
.format(Number(cal.px_per_mm)) + ' px/mm';
|
||||||
|
if (cal.calibrated_at) {
|
||||||
|
const d = new Date(cal.calibrated_at);
|
||||||
|
if (!isNaN(d)) text += ' · ' + d.toLocaleDateString(lang);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legge lo stato dal proxy: ritorna {state, calibration, calibration_url, detail}
|
||||||
|
async function fetchStationCalibration(stationId) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/maker/api/stations/${stationId}/camera/calibration`);
|
||||||
|
const body = await resp.json().catch(() => ({}));
|
||||||
|
if (!resp.ok) return { state: 'unreachable', detail: body.detail || '' };
|
||||||
|
return body;
|
||||||
|
} catch (e) {
|
||||||
|
return { state: 'unreachable', detail: String(e) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stationCalibrationCell(station, msgs) {
|
||||||
|
return {
|
||||||
|
hasCamera: !!(station.camera_url && station.camera_source),
|
||||||
|
cameraUrl: station.camera_url || '',
|
||||||
|
state: '', // '' = in lettura | calibrated | uncalibrated | unreachable
|
||||||
|
label: '',
|
||||||
|
calibrationUrl: '',
|
||||||
|
msgs: msgs || {},
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
if (!this.hasCamera) return;
|
||||||
|
const r = await fetchStationCalibration(station.id);
|
||||||
|
this.state = r.state || 'unreachable';
|
||||||
|
// Link «Acquisizione» (#88, era «Calibra»): dal proxy, altrimenti costruito dall'indirizzo della camera
|
||||||
|
this.calibrationUrl = r.calibration_url || (this.cameraUrl.replace(/\/+$/, '') + '/acquisizione');
|
||||||
|
if (this.state === 'calibrated') this.label = formatCalibration(r.calibration || {});
|
||||||
|
else if (this.state === 'uncalibrated') this.label = this.msgs.uncalibrated || 'not calibrated';
|
||||||
|
else this.label = this.msgs.unreachable || 'camera service unreachable';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -50,6 +50,7 @@
|
|||||||
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Nome') }}</th>
|
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Nome') }}</th>
|
||||||
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider hidden md:table-cell">{{ _('Postazione') }}</th>
|
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider hidden md:table-cell">{{ _('Postazione') }}</th>
|
||||||
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider hidden lg:table-cell">{{ _('Ricette collegate') }}</th>
|
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider hidden lg:table-cell">{{ _('Ricette collegate') }}</th>
|
||||||
|
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider hidden md:table-cell">{{ _('Calibrazione') }}</th>
|
||||||
<th class="text-center px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Stato') }}</th>
|
<th class="text-center px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Stato') }}</th>
|
||||||
<th class="text-right px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Azioni') }}</th>
|
<th class="text-right px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Azioni') }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -81,6 +82,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</td>
|
</td>
|
||||||
|
{# Calibrazione (#83): stato letto dall'acquisitore della camera, link alla sua pagina «Calibra» #}
|
||||||
|
<td class="px-4 py-3 hidden md:table-cell" @click.stop
|
||||||
|
x-data="stationCalibrationCell(station, {{ {'uncalibrated': _('non calibrata'), 'unreachable': _('acquisitore non raggiungibile')}|tojson_attr }})">
|
||||||
|
<template x-if="!hasCamera">
|
||||||
|
<span class="text-xs italic text-[var(--text-secondary)]">{{ _('Nessuna camera') }}</span>
|
||||||
|
</template>
|
||||||
|
<template x-if="hasCamera">
|
||||||
|
<div class="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
|
||||||
|
<span :class="state === 'calibrated' ? 'text-emerald-700 dark:text-emerald-300'
|
||||||
|
: (state === 'uncalibrated' || state === 'unreachable') ? 'text-amber-700 dark:text-amber-300'
|
||||||
|
: 'text-[var(--text-secondary)]'">
|
||||||
|
<span x-show="state === 'calibrated'">{{ _('Calibrazione') }}: </span><span x-text="state ? label : '...'"></span>
|
||||||
|
</span>
|
||||||
|
<a :href="calibrationUrl" target="_blank" rel="noopener"
|
||||||
|
x-show="calibrationUrl"
|
||||||
|
class="font-medium text-primary hover:underline">{{ _('Acquisizione') }}</a> {# #88: era «Calibra» #}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
<td class="px-4 py-3 text-center">
|
<td class="px-4 py-3 text-center">
|
||||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium"
|
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium"
|
||||||
:class="station.active
|
:class="station.active
|
||||||
@@ -218,21 +238,42 @@
|
|||||||
{# Camera della stazione (#72): servizio camera (vs-gui) e sorgente da cui prendere il frame #}
|
{# Camera della stazione (#72): servizio camera (vs-gui) e sorgente da cui prendere il frame #}
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Camera (URL)') }}</label>
|
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Camera (URL)') }}</label>
|
||||||
<input type="text" x-model="form.camera_url"
|
<input type="text" x-model="form.camera_url" @change="loadCameraSources()"
|
||||||
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
|
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
|
||||||
text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
|
text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
|
||||||
placeholder="host:8765"
|
placeholder="host:8765"
|
||||||
maxlength="500">
|
maxlength="500">
|
||||||
<p class="mt-1 text-xs text-[var(--text-secondary)]">{{ _('Vuoto = nessuna camera. Indirizzo del servizio camera, con http o https, senza / finale') }}</p>
|
<p class="mt-1 text-xs text-[var(--text-secondary)]">{{ _('Vuoto = nessuna camera. Indirizzo del servizio camera, con http o https, senza / finale') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
{# Sorgente camera (#88): testo libero con suggerimenti letti dall'acquisitore (datalist) #}
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Sorgente camera') }}</label>
|
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Sorgente camera') }}</label>
|
||||||
<input type="text" x-model="form.camera_source"
|
<div class="flex gap-2">
|
||||||
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
|
<input type="text" x-model="form.camera_source" list="cameraSourcesList"
|
||||||
text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
|
class="flex-1 min-w-0 px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
|
||||||
placeholder="camera:folder"
|
text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
|
||||||
maxlength="200">
|
placeholder="camera:folder"
|
||||||
<p class="mt-1 text-xs text-[var(--text-secondary)]">{{ _('Es. camera:<seriale> oppure camera:folder') }}</p>
|
maxlength="200">
|
||||||
|
<datalist id="cameraSourcesList">
|
||||||
|
<template x-for="s in cameraSources" :key="s.name">
|
||||||
|
<option :value="s.name" :label="s.label"></option>
|
||||||
|
</template>
|
||||||
|
</datalist>
|
||||||
|
<button type="button" @click="loadCameraSources()"
|
||||||
|
:disabled="!(form.camera_url || '').trim() || cameraSourcesLoading"
|
||||||
|
class="px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)] text-sm font-medium
|
||||||
|
text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors whitespace-nowrap">
|
||||||
|
{{ _('Leggi sorgenti') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs"
|
||||||
|
:class="cameraSourcesError ? 'text-red-600 dark:text-red-400' : 'text-[var(--text-secondary)]'"
|
||||||
|
x-text="cameraSourcesStatus || {{ _('Es. camera:<seriale> oppure camera:folder')|tojson_attr }}"></p>
|
||||||
|
<p x-show="cameraSourceMissing" class="mt-1 text-xs text-amber-700 dark:text-amber-300">{{ _("Sorgente non presente nell'acquisitore") }}</p>
|
||||||
|
<a x-show="(form.camera_url || '').trim()"
|
||||||
|
:href="(form.camera_url || '').trim().replace(/\/+$/, '') + '/acquisizione'"
|
||||||
|
target="_blank" rel="noopener"
|
||||||
|
class="mt-1 inline-block text-xs font-medium text-primary hover:underline">{{ _('Apri pagina acquisizione') }}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -459,6 +500,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
|
<script src="{{ url_for('static', filename='js/station_calibration.js') }}"></script>
|
||||||
<script>
|
<script>
|
||||||
function stationManagement(initialStations, initialRecipes) {
|
function stationManagement(initialStations, initialRecipes) {
|
||||||
return {
|
return {
|
||||||
@@ -488,6 +530,12 @@ function stationManagement(initialStations, initialRecipes) {
|
|||||||
camera_source: '', // #72
|
camera_source: '', // #72
|
||||||
active: true,
|
active: true,
|
||||||
},
|
},
|
||||||
|
// #88: sorgenti lette dall'acquisitore per il datalist della sorgente camera
|
||||||
|
cameraSources: [],
|
||||||
|
cameraSourcesLoaded: false,
|
||||||
|
cameraSourcesLoading: false,
|
||||||
|
cameraSourcesStatus: '',
|
||||||
|
cameraSourcesError: false,
|
||||||
|
|
||||||
get csrfToken() {
|
get csrfToken() {
|
||||||
const meta = document.querySelector('meta[name=csrf-token]');
|
const meta = document.querySelector('meta[name=csrf-token]');
|
||||||
@@ -535,11 +583,46 @@ function stationManagement(initialStations, initialRecipes) {
|
|||||||
return '{{ _("Nessuna ricetta assegnata") }}';
|
return '{{ _("Nessuna ricetta assegnata") }}';
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// #88: legge le sorgenti dall'acquisitore (proxy admin) e riempie il datalist
|
||||||
|
async loadCameraSources() {
|
||||||
|
const url = (this.form.camera_url || '').trim();
|
||||||
|
this.cameraSources = [];
|
||||||
|
this.cameraSourcesLoaded = false;
|
||||||
|
this.cameraSourcesError = false;
|
||||||
|
if (!url) { this.cameraSourcesStatus = ''; return; }
|
||||||
|
this.cameraSourcesLoading = true;
|
||||||
|
this.cameraSourcesStatus = '{{ _("Lettura...") }}';
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/admin/api/camera/sources?url=' + encodeURIComponent(url));
|
||||||
|
const result = await resp.json();
|
||||||
|
if (!resp.ok) {
|
||||||
|
this.cameraSourcesError = true;
|
||||||
|
this.cameraSourcesStatus = result.detail || '{{ _("Risposta non valida dal servizio camera") }}';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.cameraSources = result.sources || [];
|
||||||
|
this.cameraSourcesLoaded = true;
|
||||||
|
this.cameraSourcesStatus = '{{ _("%(n)s sorgenti trovate", n="__N__") }}'.replace('__N__', this.cameraSources.length);
|
||||||
|
} catch (e) {
|
||||||
|
this.cameraSourcesError = true;
|
||||||
|
this.cameraSourcesStatus = '{{ _("Risposta non valida dal servizio camera") }}';
|
||||||
|
} finally {
|
||||||
|
this.cameraSourcesLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// #88: avviso se la sorgente scritta non e' fra quelle lette
|
||||||
|
get cameraSourceMissing() {
|
||||||
|
const src = (this.form.camera_source || '').trim();
|
||||||
|
return this.cameraSourcesLoaded && src !== '' && !this.cameraSources.some(s => s.name === src);
|
||||||
|
},
|
||||||
|
|
||||||
openCreateModal() {
|
openCreateModal() {
|
||||||
this.isEditing = false;
|
this.isEditing = false;
|
||||||
this.editingId = null;
|
this.editingId = null;
|
||||||
this.errorMsg = '';
|
this.errorMsg = '';
|
||||||
this.form = { code: '', name: '', location: '', notes: '', vision_worker_url: '', camera_url: '', camera_source: '', active: true };
|
this.form = { code: '', name: '', location: '', notes: '', vision_worker_url: '', camera_url: '', camera_source: '', active: true };
|
||||||
|
this.loadCameraSources(); // #88: svuota lo stato delle sorgenti
|
||||||
this.showModal = true;
|
this.showModal = true;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -557,6 +640,7 @@ function stationManagement(initialStations, initialRecipes) {
|
|||||||
camera_source: station.camera_source || '', // #72
|
camera_source: station.camera_source || '', // #72
|
||||||
active: station.active,
|
active: station.active,
|
||||||
};
|
};
|
||||||
|
this.loadCameraSources(); // #88: se la camera e' valorizzata legge subito le sorgenti
|
||||||
this.showModal = true;
|
this.showModal = true;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -550,7 +550,8 @@
|
|||||||
<!-- ========== Ricetta visione (#46): solo camera_measure / dxf_compare ========== -->
|
<!-- ========== Ricetta visione (#46): solo camera_measure / dxf_compare ========== -->
|
||||||
<template x-if="task.task_type === 'camera_measure' || task.task_type === 'dxf_compare'">
|
<template x-if="task.task_type === 'camera_measure' || task.task_type === 'dxf_compare'">
|
||||||
<div class="p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]"
|
<div class="p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]"
|
||||||
x-data="visionRecipe(task.id, () => csrfToken(), {{ {'genericError': _('Errore durante il caricamento della ricetta')}|tojson_attr }})">
|
x-data="visionRecipe(task.id, () => csrfToken(), {{ {'genericError': _('Errore durante il caricamento della ricetta')}|tojson_attr }})"
|
||||||
|
@dxf-recipe-saved="init()">{# #83: dopo «Salva ricetta» da DXF si rilegge la ricetta #}
|
||||||
<p class="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wide mb-2">
|
<p class="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wide mb-2">
|
||||||
{{ _('Ricetta visione') }}
|
{{ _('Ricetta visione') }}
|
||||||
</p>
|
</p>
|
||||||
@@ -593,6 +594,93 @@
|
|||||||
<ul x-show="errors.length" class="mt-3 text-xs text-red-500 list-disc pl-5">
|
<ul x-show="errors.length" class="mt-3 text-xs text-red-500 list-disc pl-5">
|
||||||
<template x-for="(e, i) in errors" :key="i"><li x-text="e"></li></template>
|
<template x-for="(e, i) in errors" :key="i"><li x-text="e"></li></template>
|
||||||
</ul>
|
</ul>
|
||||||
|
<!-- ========== Crea ricetta da DXF (#83): solo dxf_compare ========== -->
|
||||||
|
<template x-if="task.task_type === 'dxf_compare'">
|
||||||
|
<div class="mt-4 pt-3 border-t border-[var(--border-color)]"
|
||||||
|
x-data="dxfRecipeWizard(task.id, () => csrfToken(), {{ {'requestFailed': _('Richiesta non riuscita'), 'saved': _('Ricetta salvata')}|tojson_attr }})">
|
||||||
|
<button type="button" class="btn btn-secondary text-xs" @click="toggle()">
|
||||||
|
{{ _('Crea ricetta da DXF') }}
|
||||||
|
</button>
|
||||||
|
<div x-show="open" x-cloak class="mt-3 space-y-3">
|
||||||
|
<!-- Stazione -->
|
||||||
|
<div>
|
||||||
|
<label class="tmf-label text-xs">{{ _('Stazione') }}</label>
|
||||||
|
<select class="tmf-input text-xs" x-model="stationId" @change="onStation()">
|
||||||
|
<option value="">{{ _('Scegli la stazione') }}</option>
|
||||||
|
<template x-for="st in stations" :key="st.id">
|
||||||
|
<option :value="String(st.id)" x-text="st.code + ' - ' + st.name"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
<p x-show="!stations.length" class="mt-1 text-xs text-[var(--text-muted)]">{{ _('Nessuna stazione con camera configurata') }}</p>
|
||||||
|
<p x-show="stationId && calibrated" class="mt-1 text-xs text-emerald-600">
|
||||||
|
{{ _('Calibrazione') }}: <span x-text="calLabel"></span>
|
||||||
|
</p>
|
||||||
|
<p x-show="stationId && calState && !calibrated" class="mt-1 text-xs text-amber-600">
|
||||||
|
<span x-text="calState === 'uncalibrated' ? {{ _('Stazione non calibrata: calibrala prima di creare la ricetta.')|tojson_attr }} : {{ _('Acquisitore non raggiungibile: impossibile leggere la calibrazione.')|tojson_attr }}"></span>
|
||||||
|
<a :href="calUrl" target="_blank" rel="noopener" x-show="calUrl"
|
||||||
|
class="font-medium text-primary hover:underline">{{ _('Calibra') }}</a>
|
||||||
|
<span class="text-[var(--text-muted)]">({{ _('oppure vedi la pagina Stazioni') }})</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<!-- Immagine: camera o file -->
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="tmf-label text-xs">{{ _('Immagine del pezzo') }}</label>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<button type="button" class="btn btn-secondary text-xs" @click="grab()"
|
||||||
|
:disabled="!stationId || grabbing">{{ _('Acquisisci dalla camera') }}</button>
|
||||||
|
<label class="btn btn-secondary text-xs cursor-pointer">
|
||||||
|
{{ _('Carica immagine') }}
|
||||||
|
<input type="file" accept="image/*" class="hidden" @change="onImage($event)">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-[var(--text-muted)]" x-text="imageName"></p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="tmf-label text-xs">{{ _('Disegno (.dxf)') }}</label>
|
||||||
|
<input type="file" accept=".dxf" class="tmf-input text-xs" @change="onDxf($event)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Pochi parametri -->
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="tmf-label text-xs">{{ _('Banda ± mm') }}</label>
|
||||||
|
<input type="number" step="0.01" min="0.001" class="tmf-input text-xs" x-model.number="bandMm">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="tmf-label text-xs">{{ _('Soglia punti in banda %%') }}</label>
|
||||||
|
<input type="number" step="1" min="1" max="100" class="tmf-input text-xs" x-model.number="minPointsPct">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="flex items-center gap-2 text-xs">
|
||||||
|
<input type="checkbox" x-model="dark">
|
||||||
|
{{ _('Pezzo scuro su fondo chiaro (controluce)') }}
|
||||||
|
</label>
|
||||||
|
<!-- Azioni -->
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button type="button" class="btn btn-secondary text-xs" @click="run(false)"
|
||||||
|
:disabled="!canRun">{{ _('Prova') }}</button>
|
||||||
|
<button type="button" class="btn btn-primary text-xs" @click="run(true)"
|
||||||
|
:disabled="!canRun || !result">{{ _('Salva ricetta') }}</button>
|
||||||
|
</div>
|
||||||
|
<p x-show="error" class="text-xs text-red-500" x-text="error"></p>
|
||||||
|
<p x-show="notice" class="text-xs text-emerald-600" x-text="notice"></p>
|
||||||
|
<!-- Anteprima con overlay e quattro valori -->
|
||||||
|
<div x-show="imageSrc" class="relative">
|
||||||
|
<img x-ref="img" :src="imageSrc" alt="" class="block w-full rounded border border-[var(--border-color)]"
|
||||||
|
@load="drawOverlay()">
|
||||||
|
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full pointer-events-none"></canvas>
|
||||||
|
</div>
|
||||||
|
<table x-show="result" class="tmf-table text-xs">
|
||||||
|
<tbody>
|
||||||
|
<template x-for="v in values" :key="v.key">
|
||||||
|
<tr><td class="font-mono" x-text="v.key"></td><td class="text-right" x-text="fmt(v.value)"></td></tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -1245,6 +1333,8 @@
|
|||||||
{# Bold and line breaks in descriptions: the same rules the server renders with. #}
|
{# Bold and line breaks in descriptions: the same rules the server renders with. #}
|
||||||
<script src="{{ url_for('static', filename='js/rich-text.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/rich-text.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/vision_recipe.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/vision_recipe.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/station_calibration.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/dxf_recipe_wizard.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='vendor/pdf-3.11.174.min.js') }}"></script>
|
<script src="{{ url_for('static', filename='vendor/pdf-3.11.174.min.js') }}"></script>
|
||||||
<script>if(typeof pdfjsLib!=='undefined')pdfjsLib.GlobalWorkerOptions.workerSrc='{{ url_for('static', filename='vendor/pdf.worker-3.11.174.min.js') }}';</script>
|
<script>if(typeof pdfjsLib!=='undefined')pdfjsLib.GlobalWorkerOptions.workerSrc='{{ url_for('static', filename='vendor/pdf.worker-3.11.174.min.js') }}';</script>
|
||||||
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
|
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
|
||||||
|
|||||||
@@ -254,3 +254,74 @@ def test_create_station_proxy_forwards_the_camera_fields(logged_in_client, mock_
|
|||||||
resp = logged_in_client.post("/admin/api/stations", json=body)
|
resp = logged_in_client.post("/admin/api/stations", json=body)
|
||||||
assert resp.status_code == 201
|
assert resp.status_code == 201
|
||||||
mock_admin_api.post.assert_called_once_with("/api/stations", data=body)
|
mock_admin_api.post.assert_called_once_with("/api/stations", data=body)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Sorgenti camera lette dall'acquisitore (#88) ---
|
||||||
|
|
||||||
|
def _fake_sources_response(status=200, payload=None):
|
||||||
|
resp = MagicMock()
|
||||||
|
resp.status_code = status
|
||||||
|
resp.json.return_value = payload
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def test_camera_sources_proxy_keeps_only_the_three_fields(logged_in_client, mock_admin_api):
|
||||||
|
payload = {"sources": [
|
||||||
|
{"name": "camera:folder", "label": "Cartella", "kind": "folder", "extra": 1},
|
||||||
|
{"name": "camera:123", "label": "Basler", "kind": "gige"},
|
||||||
|
]}
|
||||||
|
with patch("blueprints.admin.http_requests.get",
|
||||||
|
return_value=_fake_sources_response(200, payload)) as get:
|
||||||
|
resp = logged_in_client.get("/admin/api/camera/sources?url=http://acq:8765/")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["sources"][0] == {
|
||||||
|
"name": "camera:folder", "label": "Cartella", "kind": "folder"}
|
||||||
|
assert len(resp.get_json()["sources"]) == 2
|
||||||
|
assert get.call_args.args[0] == "http://acq:8765/camera/sources"
|
||||||
|
assert get.call_args.kwargs["timeout"] == 5
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("url", ["ftp://x", "", "http://", "acq:8765"])
|
||||||
|
def test_camera_sources_proxy_rejects_invalid_url(logged_in_client, mock_admin_api, url):
|
||||||
|
with patch("blueprints.admin.http_requests.get") as get:
|
||||||
|
resp = logged_in_client.get("/admin/api/camera/sources", query_string={"url": url})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert resp.get_json()["error"] is True
|
||||||
|
get.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_camera_sources_proxy_unreachable_is_502(logged_in_client, mock_admin_api):
|
||||||
|
import requests
|
||||||
|
with patch("blueprints.admin.http_requests.get",
|
||||||
|
side_effect=requests.ConnectionError("rifiutata")):
|
||||||
|
resp = logged_in_client.get("/admin/api/camera/sources?url=http://acq:8765")
|
||||||
|
assert resp.status_code == 502
|
||||||
|
assert "rifiutata" in resp.get_json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status,payload", [(200, {"altro": 1}), (200, {"sources": "x"}), (500, {"sources": []})])
|
||||||
|
def test_camera_sources_proxy_invalid_response_is_502(logged_in_client, mock_admin_api, status, payload):
|
||||||
|
with patch("blueprints.admin.http_requests.get",
|
||||||
|
return_value=_fake_sources_response(status, payload)):
|
||||||
|
resp = logged_in_client.get("/admin/api/camera/sources?url=http://acq:8765")
|
||||||
|
assert resp.status_code == 502
|
||||||
|
|
||||||
|
|
||||||
|
def test_camera_sources_proxy_non_admin_is_redirected(client, mock_admin_api):
|
||||||
|
with client.session_transaction() as sess:
|
||||||
|
sess["api_key"] = "test-key"
|
||||||
|
sess["user"] = {"id": 2, "username": "operator", "roles": ["MeasurementTec"],
|
||||||
|
"is_admin": False, "active": True}
|
||||||
|
sess["user_id"] = 2
|
||||||
|
with patch("blueprints.admin.http_requests.get") as get:
|
||||||
|
resp = client.get("/admin/api/camera/sources?url=http://acq:8765")
|
||||||
|
assert resp.status_code in (301, 302)
|
||||||
|
get.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_station_form_has_the_camera_sources_picker(logged_in_client, mock_admin_api):
|
||||||
|
mock_admin_api.get.return_value = []
|
||||||
|
html = logged_in_client.get("/admin/stations").get_data(as_text=True)
|
||||||
|
assert "cameraSourcesList" in html
|
||||||
|
assert "loadCameraSources" in html
|
||||||
|
assert "/acquisizione" in html
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
"""gitea:Adriano/TieMeasureFlow#83 - ricetta DXF compare da camera: rotte Flask nuove.
|
||||||
|
|
||||||
|
api_client e requests sono finti: backend (B3) e acquisitore (C1) rispondono
|
||||||
|
come da contratto del piano.
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from tests.conftest import api_get_router
|
||||||
|
|
||||||
|
STATION = {
|
||||||
|
"id": 4, "code": "ST-4", "name": "Banco",
|
||||||
|
"camera_url": "http://acq:8765/", "camera_source": "camera:folder",
|
||||||
|
}
|
||||||
|
NO_CAMERA = {"id": 5, "code": "ST-5", "name": "Senza", "camera_url": None, "camera_source": None}
|
||||||
|
PNG = b"\x89PNG\r\n\x1a\n fake"
|
||||||
|
CAL = {
|
||||||
|
"source": "camera:folder", "px_per_mm": 20.03, "mm_per_px": 0.0499, "rms_px": 0.2,
|
||||||
|
"n_corners": 54, "cols": 9, "rows": 6, "square_mm": 5.0,
|
||||||
|
"image_width": 2448, "image_height": 2048, "scale_spread_pct": 0.3,
|
||||||
|
"calibrated_at": "2026-10-23T10:00:00",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _fake(status=200, content=PNG, json_body=None):
|
||||||
|
resp = MagicMock(status_code=status, content=content, text=content.decode("latin-1"))
|
||||||
|
if json_body is None:
|
||||||
|
resp.json.side_effect = ValueError("no json")
|
||||||
|
else:
|
||||||
|
resp.json.return_value = json_body
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def _stations(mock_api_client, stations=(STATION, NO_CAMERA)):
|
||||||
|
mock_api_client.get.side_effect = api_get_router({"/api/stations": list(stations)})
|
||||||
|
|
||||||
|
|
||||||
|
class TestStationList:
|
||||||
|
def test_only_stations_with_camera(self, logged_in_client, mock_api_client):
|
||||||
|
_stations(mock_api_client)
|
||||||
|
resp = logged_in_client.get("/maker/api/stations")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert [s["id"] for s in resp.get_json()] == [4]
|
||||||
|
|
||||||
|
def test_non_admin_list_falls_back_to_current_station(
|
||||||
|
self, logged_in_client, mock_api_client, monkeypatch):
|
||||||
|
# /api/stations e' solo admin: il Maker vede la stazione corrente (by-code)
|
||||||
|
monkeypatch.setattr("blueprints.maker.Config.STATION_CODE", "ST-4")
|
||||||
|
mock_api_client.get.side_effect = api_get_router(
|
||||||
|
{"/api/stations": {"error": True, "status_code": 403, "detail": "no"}})
|
||||||
|
mock_api_client.get_station_by_code.return_value = STATION
|
||||||
|
resp = logged_in_client.get("/maker/api/stations")
|
||||||
|
assert [s["id"] for s in resp.get_json()] == [4]
|
||||||
|
mock_api_client.get_station_by_code.assert_called_once_with("ST-4")
|
||||||
|
|
||||||
|
def test_requires_maker_or_admin(self, logged_in_client, mock_api_client):
|
||||||
|
with logged_in_client.session_transaction() as sess:
|
||||||
|
sess["user"] = {**sess["user"], "roles": ["MeasurementTec"], "is_admin": False}
|
||||||
|
assert logged_in_client.get("/maker/api/stations").status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
class TestStationCameraGrab:
|
||||||
|
def test_png_pass_through(self, logged_in_client, mock_api_client):
|
||||||
|
_stations(mock_api_client)
|
||||||
|
with patch("blueprints.maker.http_requests.post", return_value=_fake()) as post:
|
||||||
|
resp = logged_in_client.post("/maker/api/stations/4/camera/grab")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.mimetype == "image/png"
|
||||||
|
assert resp.data == PNG
|
||||||
|
args, kwargs = post.call_args
|
||||||
|
assert args[0] == "http://acq:8765/camera/grab" # slash finale tolto
|
||||||
|
assert kwargs["json"] == {"source": "camera:folder", "trigger": "continuo", "timeout_ms": 5000}
|
||||||
|
|
||||||
|
def test_get_also_works(self, logged_in_client, mock_api_client):
|
||||||
|
_stations(mock_api_client)
|
||||||
|
with patch("blueprints.maker.http_requests.post", return_value=_fake()):
|
||||||
|
assert logged_in_client.get("/maker/api/stations/4/camera/grab").status_code == 200
|
||||||
|
|
||||||
|
def test_station_without_camera_is_409(self, logged_in_client, mock_api_client):
|
||||||
|
_stations(mock_api_client)
|
||||||
|
with patch("blueprints.maker.http_requests.post") as post:
|
||||||
|
resp = logged_in_client.post("/maker/api/stations/5/camera/grab")
|
||||||
|
assert resp.status_code == 409
|
||||||
|
post.assert_not_called()
|
||||||
|
|
||||||
|
def test_unknown_station_is_409(self, logged_in_client, mock_api_client):
|
||||||
|
_stations(mock_api_client)
|
||||||
|
assert logged_in_client.post("/maker/api/stations/99/camera/grab").status_code == 409
|
||||||
|
|
||||||
|
def test_service_down_is_502(self, logged_in_client, mock_api_client):
|
||||||
|
_stations(mock_api_client)
|
||||||
|
with patch("blueprints.maker.http_requests.post",
|
||||||
|
side_effect=requests.ConnectionError("refused")):
|
||||||
|
resp = logged_in_client.post("/maker/api/stations/4/camera/grab")
|
||||||
|
assert resp.status_code == 502
|
||||||
|
assert resp.get_json()["error"]
|
||||||
|
|
||||||
|
def test_service_503_is_502_with_detail(self, logged_in_client, mock_api_client):
|
||||||
|
_stations(mock_api_client)
|
||||||
|
fake = _fake(503, b"{}", {"detail": "camera occupata"})
|
||||||
|
with patch("blueprints.maker.http_requests.post", return_value=fake):
|
||||||
|
resp = logged_in_client.post("/maker/api/stations/4/camera/grab")
|
||||||
|
assert resp.status_code == 502
|
||||||
|
assert "camera occupata" in resp.get_json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestStationCameraCalibration:
|
||||||
|
def _get(self, client, fake=None, exc=None):
|
||||||
|
with patch("blueprints.maker.http_requests.get", return_value=fake,
|
||||||
|
side_effect=exc) as get:
|
||||||
|
resp = client.get("/maker/api/stations/4/camera/calibration")
|
||||||
|
return resp, get
|
||||||
|
|
||||||
|
def test_calibrated(self, logged_in_client, mock_api_client):
|
||||||
|
_stations(mock_api_client)
|
||||||
|
resp, get = self._get(logged_in_client, _fake(200, b"{}", CAL))
|
||||||
|
body = resp.get_json()
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert body["state"] == "calibrated"
|
||||||
|
assert body["calibration"]["px_per_mm"] == 20.03
|
||||||
|
assert body["calibration_url"] == "http://acq:8765/acquisizione"
|
||||||
|
args, kwargs = get.call_args
|
||||||
|
assert args[0] == "http://acq:8765/camera/calibration"
|
||||||
|
assert kwargs["params"] == {"source": "camera:folder"}
|
||||||
|
|
||||||
|
def test_404_is_uncalibrated(self, logged_in_client, mock_api_client):
|
||||||
|
_stations(mock_api_client)
|
||||||
|
resp, _ = self._get(logged_in_client, _fake(404, b"{}", {"detail": "no"}))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["state"] == "uncalibrated"
|
||||||
|
assert resp.get_json()["calibration_url"] == "http://acq:8765/acquisizione"
|
||||||
|
|
||||||
|
def test_unreachable(self, logged_in_client, mock_api_client):
|
||||||
|
_stations(mock_api_client)
|
||||||
|
resp, _ = self._get(logged_in_client, exc=requests.ConnectionError("down"))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["state"] == "unreachable"
|
||||||
|
|
||||||
|
def test_server_error_is_unreachable(self, logged_in_client, mock_api_client):
|
||||||
|
_stations(mock_api_client)
|
||||||
|
resp, _ = self._get(logged_in_client, _fake(500, b"boom"))
|
||||||
|
assert resp.get_json()["state"] == "unreachable"
|
||||||
|
|
||||||
|
def test_station_without_camera_is_409(self, logged_in_client, mock_api_client):
|
||||||
|
_stations(mock_api_client)
|
||||||
|
with patch("blueprints.maker.http_requests.get") as get:
|
||||||
|
resp = logged_in_client.get("/maker/api/stations/5/camera/calibration")
|
||||||
|
assert resp.status_code == 409
|
||||||
|
get.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestDxfCompareProxy:
|
||||||
|
URL = "/maker/api/vision/tasks/7/recipe/dxf-compare"
|
||||||
|
OK = {
|
||||||
|
"result": {"outputs": {"dxf.points_in_pct": 99.1}, "overlay": [], "outcome": "pass"},
|
||||||
|
"saved": False, "recipe_dir": None, "outputs": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _post(self, client, **form):
|
||||||
|
data = {"image": (io.BytesIO(PNG), "p.png"), "dxf": (io.BytesIO(b"0\nEOF"), "d.dxf"), **form}
|
||||||
|
return client.post(self.URL, data=data, content_type="multipart/form-data")
|
||||||
|
|
||||||
|
def test_forwards_files_and_fields(self, logged_in_client, mock_api_client):
|
||||||
|
mock_api_client.post.return_value = self.OK
|
||||||
|
resp = self._post(logged_in_client, station_id="4", band_mm="0.1",
|
||||||
|
min_points_pct="95", polarity="dark", save="false", px_per_mm="")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["result"]["outputs"]["dxf.points_in_pct"] == 99.1
|
||||||
|
args, kwargs = mock_api_client.post.call_args
|
||||||
|
assert args[0] == "/api/vision/tasks/7/recipe/dxf-compare"
|
||||||
|
# campi del contratto B3; px_per_mm vuoto non si inoltra
|
||||||
|
assert kwargs["data"] == {
|
||||||
|
"station_id": "4", "band_mm": "0.1", "min_points_pct": "95",
|
||||||
|
"polarity": "dark", "save": "false",
|
||||||
|
}
|
||||||
|
assert set(kwargs["files"]) == {"image", "dxf"}
|
||||||
|
assert kwargs["files"]["image"][1] == PNG
|
||||||
|
|
||||||
|
def test_save_true_forwarded(self, logged_in_client, mock_api_client):
|
||||||
|
mock_api_client.post.return_value = {**self.OK, "saved": True, "recipe_dir": "vision/recipes/7/x"}
|
||||||
|
resp = self._post(logged_in_client, save="true")
|
||||||
|
assert resp.get_json()["saved"] is True
|
||||||
|
assert mock_api_client.post.call_args.kwargs["data"]["save"] == "true"
|
||||||
|
|
||||||
|
def test_missing_files_is_400(self, logged_in_client, mock_api_client):
|
||||||
|
resp = logged_in_client.post(self.URL, data={"station_id": "4"},
|
||||||
|
content_type="multipart/form-data")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
mock_api_client.post.assert_not_called()
|
||||||
|
|
||||||
|
def test_backend_422_propagates(self, logged_in_client, mock_api_client):
|
||||||
|
mock_api_client.post.return_value = {
|
||||||
|
"error": True, "status_code": 422, "detail": "stazione non calibrata"}
|
||||||
|
resp = self._post(logged_in_client)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.get_json()["detail"] == "stazione non calibrata"
|
||||||
|
|
||||||
|
def test_requires_maker_role(self, logged_in_client, mock_api_client):
|
||||||
|
with logged_in_client.session_transaction() as sess:
|
||||||
|
sess["user"] = {**sess["user"], "roles": ["MeasurementTec"]}
|
||||||
|
assert self._post(logged_in_client).status_code == 403
|
||||||
|
mock_api_client.post.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestPages:
|
||||||
|
def test_stations_page_has_calibration_cell(self, logged_in_client):
|
||||||
|
with patch("blueprints.admin.api_client") as admin_api:
|
||||||
|
admin_api.get.side_effect = api_get_router({"/api/stations": [STATION], "/api/recipes": []})
|
||||||
|
html = logged_in_client.get("/admin/stations").get_data(as_text=True)
|
||||||
|
assert "js/station_calibration.js" in html
|
||||||
|
assert "stationCalibrationCell(" in html
|
||||||
|
# niente piu' pannello di calibrazione nella pagina: solo il link all'acquisitore
|
||||||
|
assert "calibration-panel" not in html
|
||||||
|
|
||||||
|
def _editor(self, client, mock_api_client, task_type):
|
||||||
|
mock_api_client.get.side_effect = api_get_router({
|
||||||
|
"/api/recipes/3": {"id": 3, "code": "R", "name": "R"},
|
||||||
|
"/api/recipes/3/tasks": [{
|
||||||
|
"id": 11, "title": "T", "task_type": task_type, "subtasks": [],
|
||||||
|
"file_path": None, "order_index": 1,
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
return client.get("/maker/recipes/3/tasks").get_data(as_text=True)
|
||||||
|
|
||||||
|
def test_editor_has_dxf_wizard(self, logged_in_client, mock_api_client):
|
||||||
|
html = self._editor(logged_in_client, mock_api_client, "dxf_compare")
|
||||||
|
assert "js/dxf_recipe_wizard.js" in html
|
||||||
|
assert "dxfRecipeWizard(" in html
|
||||||
|
assert "@dxf-recipe-saved" in html
|
||||||
|
assert "Pezzo scuro su fondo chiaro" in html or "Dark part on light" in html
|
||||||
@@ -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 = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3359,3 +3359,129 @@ msgstr "Camera service unreachable: %(detail)s"
|
|||||||
#, python-format
|
#, python-format
|
||||||
msgid "Acquisizione non riuscita (%(status)s): %(detail)s"
|
msgid "Acquisizione non riuscita (%(status)s): %(detail)s"
|
||||||
msgstr "Capture failed (%(status)s): %(detail)s"
|
msgstr "Capture failed (%(status)s): %(detail)s"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||||
|
#, python-format
|
||||||
|
msgid "Risposta %(status)s dall'acquisitore"
|
||||||
|
msgstr "Response %(status)s from the camera service"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||||
|
msgid "Risposta non valida dall'acquisitore"
|
||||||
|
msgstr "Invalid response from the camera service"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||||
|
msgid "Servono l'immagine e il file DXF"
|
||||||
|
msgstr "Both the image and the DXF file are required"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||||
|
msgid "Calibrazione"
|
||||||
|
msgstr "Calibration"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||||
|
msgid "non calibrata"
|
||||||
|
msgstr "not calibrated"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||||
|
msgid "acquisitore non raggiungibile"
|
||||||
|
msgstr "camera service unreachable"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||||
|
msgid "Nessuna camera"
|
||||||
|
msgstr "No camera"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||||
|
msgid "Calibra"
|
||||||
|
msgstr "Calibrate"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||||
|
msgid "Ricetta salvata"
|
||||||
|
msgstr "Recipe saved"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||||
|
msgid "Crea ricetta da DXF"
|
||||||
|
msgstr "Create recipe from DXF"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||||
|
msgid "Scegli la stazione"
|
||||||
|
msgstr "Choose the station"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||||
|
msgid "Nessuna stazione con camera configurata"
|
||||||
|
msgstr "No station with a camera configured"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||||
|
msgid "Stazione non calibrata: calibrala prima di creare la ricetta."
|
||||||
|
msgstr "Station not calibrated: calibrate it before creating the recipe."
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||||
|
msgid "Acquisitore non raggiungibile: impossibile leggere la calibrazione."
|
||||||
|
msgstr "Camera service unreachable: cannot read the calibration."
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||||
|
msgid "oppure vedi la pagina Stazioni"
|
||||||
|
msgstr "or see the Stations page"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||||
|
msgid "Immagine del pezzo"
|
||||||
|
msgstr "Part image"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||||
|
msgid "Carica immagine"
|
||||||
|
msgstr "Upload image"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||||
|
msgid "Disegno (.dxf)"
|
||||||
|
msgstr "Drawing (.dxf)"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||||
|
msgid "Banda ± mm"
|
||||||
|
msgstr "Band ± mm"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||||
|
msgid "Soglia punti in banda %%"
|
||||||
|
msgstr "Points-in-band threshold %%"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||||
|
msgid "Pezzo scuro su fondo chiaro (controluce)"
|
||||||
|
msgstr "Dark part on light background (backlight)"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||||
|
msgid "Prova"
|
||||||
|
msgstr "Try"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||||
|
msgid "Salva ricetta"
|
||||||
|
msgstr "Save recipe"
|
||||||
|
|
||||||
|
#: templates/admin/stations.html blueprints/admin.py (#88)
|
||||||
|
msgid "Acquisizione"
|
||||||
|
msgstr "Acquisition"
|
||||||
|
|
||||||
|
#: templates/admin/stations.html blueprints/admin.py (#88)
|
||||||
|
msgid "Leggi sorgenti"
|
||||||
|
msgstr "Read sources"
|
||||||
|
|
||||||
|
#: templates/admin/stations.html blueprints/admin.py (#88)
|
||||||
|
msgid "Lettura..."
|
||||||
|
msgstr "Reading..."
|
||||||
|
|
||||||
|
#: templates/admin/stations.html blueprints/admin.py (#88)
|
||||||
|
#, python-format
|
||||||
|
msgid "%(n)s sorgenti trovate"
|
||||||
|
msgstr "%(n)s sources found"
|
||||||
|
|
||||||
|
#: templates/admin/stations.html blueprints/admin.py (#88)
|
||||||
|
msgid "Sorgente non presente nell'acquisitore"
|
||||||
|
msgstr "Source not present in the acquisition service"
|
||||||
|
|
||||||
|
#: templates/admin/stations.html blueprints/admin.py (#88)
|
||||||
|
msgid "Apri pagina acquisizione"
|
||||||
|
msgstr "Open acquisition page"
|
||||||
|
|
||||||
|
#: templates/admin/stations.html blueprints/admin.py (#88)
|
||||||
|
msgid "Indirizzo della camera non valido"
|
||||||
|
msgstr "Invalid camera address"
|
||||||
|
|
||||||
|
#: templates/admin/stations.html blueprints/admin.py (#88)
|
||||||
|
msgid "Risposta non valida dal servizio camera"
|
||||||
|
msgstr "Invalid response from the camera service"
|
||||||
|
|||||||
@@ -3347,3 +3347,129 @@ msgstr "Servizio camera non raggiungibile: %(detail)s"
|
|||||||
#, python-format
|
#, python-format
|
||||||
msgid "Acquisizione non riuscita (%(status)s): %(detail)s"
|
msgid "Acquisizione non riuscita (%(status)s): %(detail)s"
|
||||||
msgstr "Acquisizione non riuscita (%(status)s): %(detail)s"
|
msgstr "Acquisizione non riuscita (%(status)s): %(detail)s"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||||
|
#, python-format
|
||||||
|
msgid "Risposta %(status)s dall'acquisitore"
|
||||||
|
msgstr "Risposta %(status)s dall'acquisitore"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||||
|
msgid "Risposta non valida dall'acquisitore"
|
||||||
|
msgstr "Risposta non valida dall'acquisitore"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||||
|
msgid "Servono l'immagine e il file DXF"
|
||||||
|
msgstr "Servono l'immagine e il file DXF"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||||
|
msgid "Calibrazione"
|
||||||
|
msgstr "Calibrazione"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||||
|
msgid "non calibrata"
|
||||||
|
msgstr "non calibrata"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||||
|
msgid "acquisitore non raggiungibile"
|
||||||
|
msgstr "acquisitore non raggiungibile"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||||
|
msgid "Nessuna camera"
|
||||||
|
msgstr "Nessuna camera"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||||
|
msgid "Calibra"
|
||||||
|
msgstr "Calibra"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||||
|
msgid "Ricetta salvata"
|
||||||
|
msgstr "Ricetta salvata"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||||
|
msgid "Crea ricetta da DXF"
|
||||||
|
msgstr "Crea ricetta da DXF"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||||
|
msgid "Scegli la stazione"
|
||||||
|
msgstr "Scegli la stazione"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||||
|
msgid "Nessuna stazione con camera configurata"
|
||||||
|
msgstr "Nessuna stazione con camera configurata"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||||
|
msgid "Stazione non calibrata: calibrala prima di creare la ricetta."
|
||||||
|
msgstr "Stazione non calibrata: calibrala prima di creare la ricetta."
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||||
|
msgid "Acquisitore non raggiungibile: impossibile leggere la calibrazione."
|
||||||
|
msgstr "Acquisitore non raggiungibile: impossibile leggere la calibrazione."
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||||
|
msgid "oppure vedi la pagina Stazioni"
|
||||||
|
msgstr "oppure vedi la pagina Stazioni"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||||
|
msgid "Immagine del pezzo"
|
||||||
|
msgstr "Immagine del pezzo"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||||
|
msgid "Carica immagine"
|
||||||
|
msgstr "Carica immagine"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||||
|
msgid "Disegno (.dxf)"
|
||||||
|
msgstr "Disegno (.dxf)"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||||
|
msgid "Banda ± mm"
|
||||||
|
msgstr "Banda ± mm"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||||
|
msgid "Soglia punti in banda %%"
|
||||||
|
msgstr "Soglia punti in banda %%"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||||
|
msgid "Pezzo scuro su fondo chiaro (controluce)"
|
||||||
|
msgstr "Pezzo scuro su fondo chiaro (controluce)"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||||
|
msgid "Prova"
|
||||||
|
msgstr "Prova"
|
||||||
|
|
||||||
|
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||||
|
msgid "Salva ricetta"
|
||||||
|
msgstr "Salva ricetta"
|
||||||
|
|
||||||
|
#: templates/admin/stations.html blueprints/admin.py (#88)
|
||||||
|
msgid "Acquisizione"
|
||||||
|
msgstr "Acquisizione"
|
||||||
|
|
||||||
|
#: templates/admin/stations.html blueprints/admin.py (#88)
|
||||||
|
msgid "Leggi sorgenti"
|
||||||
|
msgstr "Leggi sorgenti"
|
||||||
|
|
||||||
|
#: templates/admin/stations.html blueprints/admin.py (#88)
|
||||||
|
msgid "Lettura..."
|
||||||
|
msgstr "Lettura..."
|
||||||
|
|
||||||
|
#: templates/admin/stations.html blueprints/admin.py (#88)
|
||||||
|
#, python-format
|
||||||
|
msgid "%(n)s sorgenti trovate"
|
||||||
|
msgstr "%(n)s sorgenti trovate"
|
||||||
|
|
||||||
|
#: templates/admin/stations.html blueprints/admin.py (#88)
|
||||||
|
msgid "Sorgente non presente nell'acquisitore"
|
||||||
|
msgstr "Sorgente non presente nell'acquisitore"
|
||||||
|
|
||||||
|
#: templates/admin/stations.html blueprints/admin.py (#88)
|
||||||
|
msgid "Apri pagina acquisizione"
|
||||||
|
msgstr "Apri pagina acquisizione"
|
||||||
|
|
||||||
|
#: templates/admin/stations.html blueprints/admin.py (#88)
|
||||||
|
msgid "Indirizzo della camera non valido"
|
||||||
|
msgstr "Indirizzo della camera non valido"
|
||||||
|
|
||||||
|
#: templates/admin/stations.html blueprints/admin.py (#88)
|
||||||
|
msgid "Risposta non valida dal servizio camera"
|
||||||
|
msgstr "Risposta non valida dal servizio camera"
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
"""Costruttore di ricette DXF compare dal campo (immagine + DXF + scala -> ricetta v2).
|
||||||
|
|
||||||
|
Logica pura, niente FastAPI ne' database: il worker (src/vision_worker/main.py)
|
||||||
|
lo espone come /recipes/dxf-compare, i test lo chiamano direttamente. Tutta la
|
||||||
|
visione sta in TieVision-Core (visionsuite.*). La calibrazione a scacchiera NON
|
||||||
|
sta qui: vive nell'acquisitore (scripts/calibrazione.py, #83).
|
||||||
|
|
||||||
|
Errori di input (pezzo non trovato, DXF illeggibile, parametri fuori range)
|
||||||
|
sono `CostruttoreError`: il worker li mappa a 422.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import cv2 # #86: soglia/componenti connesse per la maschera del pezzo
|
||||||
|
import numpy as np
|
||||||
|
from visionsuite.core.image import Image
|
||||||
|
|
||||||
|
from src.vision.runner import RunOutcome, run_graph
|
||||||
|
|
||||||
|
|
||||||
|
class CostruttoreError(ValueError):
|
||||||
|
"""Input che non permette di costruire (422 per il worker), non un guasto."""
|
||||||
|
|
||||||
|
|
||||||
|
# Nomi/id fissi della ricetta (contratto #83 con backend e frontend).
|
||||||
|
DXF_FILE = "disegno.dxf"
|
||||||
|
CALIBRATION_FILE = "calibrazione.json"
|
||||||
|
_POLARITIES = ("dark", "light")
|
||||||
|
# Pose iniziali del disegno provate (gradi): il verso del pezzo non e' noto.
|
||||||
|
_OUTPUT_KEYS = ("dxf.points_in_pct", "dxf.distance_avg", "dxf.distance_min", "dxf.distance_max")
|
||||||
|
_START_ANGLES = (0.0, 90.0, 180.0, 270.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _b64(data: bytes) -> str:
|
||||||
|
return base64.b64encode(data).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _gray_u8(pixels: np.ndarray) -> np.ndarray:
|
||||||
|
"""Immagine -> grigio uint8 2D (LocateBlob accetta solo uint8)."""
|
||||||
|
px = np.asarray(pixels)
|
||||||
|
if px.ndim == 3:
|
||||||
|
px = px[..., 0] # multicanale: il primo, come fa LocateBlob
|
||||||
|
if px.dtype != np.uint8:
|
||||||
|
# float/uint16 -> scala sul massimo, come _to_gray_uint8 di vs-core
|
||||||
|
m = float(px.max()) if px.size else 1.0
|
||||||
|
px = np.clip(px.astype(np.float32) / max(m, 1e-9) * 255, 0, 255).astype(np.uint8)
|
||||||
|
return np.ascontiguousarray(px)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ W2
|
||||||
|
|
||||||
|
def _graph(dxf_sha: str, cal_sha: str, band_mm: float, min_points_pct: float, polarity: str,
|
||||||
|
reference_pose: dict, anchor: tuple[float, float, float] | None) -> dict:
|
||||||
|
"""Ricetta v2 fissa: LocateBlob "pezzo" + CompareDxf "dxf" agganciato a `align`.
|
||||||
|
|
||||||
|
`anchor` = (riga, colonna, angolo) dell'origine del disegno nell'immagine di
|
||||||
|
riferimento; None = solo il localizzatore (passo intermedio).
|
||||||
|
"""
|
||||||
|
tools: list[dict] = [{
|
||||||
|
"id": "pezzo", "name": "Pezzo", "kind": "LocateBlob",
|
||||||
|
"values": {"polarity": polarity, "reference_pose": reference_pose},
|
||||||
|
"links": {},
|
||||||
|
}]
|
||||||
|
if anchor is not None:
|
||||||
|
row, col, angle = anchor
|
||||||
|
tools.append({
|
||||||
|
"id": "dxf", "name": "Confronto DXF", "kind": "CompareDxf",
|
||||||
|
"values": {
|
||||||
|
"dxf": "disegno",
|
||||||
|
"anchor": "align",
|
||||||
|
"anchor_offset_row_px": float(row),
|
||||||
|
"anchor_offset_col_px": float(col),
|
||||||
|
"offset_angle_deg": float(angle),
|
||||||
|
"band_plus": float(band_mm),
|
||||||
|
"band_minus": float(band_mm),
|
||||||
|
"nominal_unit": "mm",
|
||||||
|
# tolleranza di points_in_pct: nominale = limite, plus = 100 - limite
|
||||||
|
"points_in_pct_nominal": float(min_points_pct),
|
||||||
|
"points_in_pct_plus": float(100.0 - min_points_pct),
|
||||||
|
"points_in_pct_minus": 0.0,
|
||||||
|
},
|
||||||
|
"links": {"align": {"tool": "pezzo", "property": "poses"}},
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"schema_version": 2,
|
||||||
|
"name": "Confronto DXF",
|
||||||
|
# sha256 obbligatorio per vs-task (verifica i file della cartella ricetta)
|
||||||
|
"meta": {"dxf": {"disegno": {"path": DXF_FILE, "sha256": dxf_sha}},
|
||||||
|
"calibration": {"path": CALIBRATION_FILE, "sha256": cal_sha}},
|
||||||
|
"tools": tools,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _locate_reference(gray: np.ndarray, polarity: str) -> dict:
|
||||||
|
"""Posa del pezzo sull'immagine di riferimento (LocateBlob, soglia Otsu)."""
|
||||||
|
from visionsuite.task import Task, load_tools
|
||||||
|
|
||||||
|
load_tools()
|
||||||
|
graph = _graph('', '', 1.0, 95.0, polarity, {"row": 0.0, "col": 0.0, "angle_deg": 0.0}, None)
|
||||||
|
task = Task.from_dict(graph)
|
||||||
|
task.run(context={"image": Image(gray)})
|
||||||
|
pezzo = task["Pezzo"]
|
||||||
|
if not pezzo.found_poses:
|
||||||
|
raise CostruttoreError(
|
||||||
|
"pezzo non trovato nell'immagine di riferimento "
|
||||||
|
f"(polarity={polarity}): controllare luce, sfondo e polarita'"
|
||||||
|
)
|
||||||
|
return pezzo.found_poses[0].to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
def _maschera_pezzo(gray: np.ndarray, polarity: str, px_per_mm: float = 71.0) -> np.ndarray:
|
||||||
|
"""Immagine con solo il pezzo: fuori dal blob staccato dal bordo e' sfondo (#86).
|
||||||
|
|
||||||
|
Otsu (INV se dark), componenti connesse, la piu' grande che NON tocca il
|
||||||
|
bordo immagine (piastre del prisma, ombre e angoli neri della foto
|
||||||
|
raddrizzata ci sono attaccati), dilatata di ~0,3 mm (minimo 15 px).
|
||||||
|
"""
|
||||||
|
flag = cv2.THRESH_BINARY_INV if polarity == "dark" else cv2.THRESH_BINARY
|
||||||
|
_, binary = cv2.threshold(gray, 0, 255, flag | cv2.THRESH_OTSU)
|
||||||
|
n, labels, stats, _ = cv2.connectedComponentsWithStats(binary, connectivity=8)
|
||||||
|
h, w = gray.shape[:2]
|
||||||
|
best, best_area = 0, 0
|
||||||
|
for i in range(1, n): # 0 = sfondo
|
||||||
|
x, y, cw, ch, area = (int(v) for v in stats[i])
|
||||||
|
if x == 0 or y == 0 or x + cw >= w or y + ch >= h:
|
||||||
|
continue # tocca il bordo immagine: non e' il pezzo
|
||||||
|
if area > best_area:
|
||||||
|
best, best_area = i, area
|
||||||
|
if best == 0:
|
||||||
|
raise CostruttoreError("pezzo non trovato: nessun blob staccato dal bordo")
|
||||||
|
raggio = max(15, int(round(0.3 * px_per_mm))) # margine in funzione della scala
|
||||||
|
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (2 * raggio + 1, 2 * raggio + 1))
|
||||||
|
zona = cv2.dilate((labels == best).astype(np.uint8), kernel) > 0
|
||||||
|
sfondo = 255 if polarity == "dark" else 0
|
||||||
|
return np.where(zona, gray, np.uint8(sfondo)).astype(np.uint8)
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate_anchors(gray: np.ndarray, dxf_path: Path, px_per_mm: float,
|
||||||
|
polarity: str = "dark") -> list[tuple[float, float, float]]:
|
||||||
|
"""Pose (riga, col, angolo) del disegno sull'immagine: ICP da 4 orientamenti.
|
||||||
|
|
||||||
|
Il disegno e' in px (origine DXF, y capovolta); si parte da ognuno dei 4
|
||||||
|
angoli con i centroidi sovrapposti e l'ICP rifinisce. Ritorna le posa trovate
|
||||||
|
(anche per orientamenti che non convergono: le giudica la ricetta eseguita).
|
||||||
|
Centroide e ICP lavorano sull'immagine mascherata sul solo pezzo (#86).
|
||||||
|
"""
|
||||||
|
from visionsuite.calibration import ScaleCalibration
|
||||||
|
from visionsuite.dxf.compare import DXFCompare, _stack
|
||||||
|
from visionsuite.task.pose import Pose
|
||||||
|
|
||||||
|
cmp_ = DXFCompare(dxf_path, ScaleCalibration(px_per_mm=px_per_mm))
|
||||||
|
drawing = _stack(cmp_.drawing_contours())
|
||||||
|
gray = _maschera_pezzo(gray, polarity, px_per_mm) # #86: via piastre, ombre, angoli neri
|
||||||
|
measured = _stack(cmp_.measured_contours(Image(gray)))
|
||||||
|
if len(drawing) < 3:
|
||||||
|
raise CostruttoreError("DXF senza geometria utilizzabile")
|
||||||
|
if len(measured) < 3:
|
||||||
|
raise CostruttoreError("pezzo non trovato: nessun bordo nell'immagine")
|
||||||
|
dc, mc = drawing.mean(axis=0), measured.mean(axis=0)
|
||||||
|
|
||||||
|
anchors = []
|
||||||
|
errori = [] # #86: motivo di ogni orientamento caduto, per la diagnosi in campo
|
||||||
|
for theta in _START_ANGLES:
|
||||||
|
# traslazione che porta il centroide del disegno ruotato sul misurato
|
||||||
|
rr, cc = Pose(0.0, 0.0, theta).apply(dc[0], dc[1])
|
||||||
|
start = Pose(float(mc[0] - rr), float(mc[1] - cc), theta)
|
||||||
|
try:
|
||||||
|
result = cmp_.compare(Image(gray), initial_pose=start)
|
||||||
|
except Exception as exc: # noqa: BLE001 - un orientamento che cade non ferma gli altri
|
||||||
|
errori.append(f"{theta:g}gradi: {type(exc).__name__}: {exc}") # #86
|
||||||
|
continue
|
||||||
|
# transform: misurato -> disegno; la posa del disegno e' l'inversa
|
||||||
|
m = np.linalg.inv(result.transform)
|
||||||
|
angle = math.degrees(math.atan2(m[1, 0], m[0, 0]))
|
||||||
|
anchors.append((float(m[0, 2]), float(m[1, 2]), angle))
|
||||||
|
if not anchors and errori: # #86: nessuna ancora -> il motivo risale al chiamante
|
||||||
|
raise CostruttoreError("disegno non sovrapponibile al pezzo (ICP fallito): " + "; ".join(errori))
|
||||||
|
return anchors
|
||||||
|
|
||||||
|
|
||||||
|
def _run(gray, graph, px_per_mm, folder) -> RunOutcome:
|
||||||
|
return run_graph(gray, graph, calibration={"px_per_mm": px_per_mm}, recipe_dir=folder)
|
||||||
|
|
||||||
|
|
||||||
|
def build_dxf_compare(
|
||||||
|
gray,
|
||||||
|
dxf_bytes: bytes,
|
||||||
|
px_per_mm: float,
|
||||||
|
band_mm: float = 0.1,
|
||||||
|
min_points_pct: float = 95.0,
|
||||||
|
polarity: str = "dark",
|
||||||
|
) -> dict:
|
||||||
|
"""Ricetta DXF compare dall'immagine di riferimento + disegno + scala.
|
||||||
|
|
||||||
|
Ritorna {graph, files, outputs, overlay, outcome}. `files` sono i due file
|
||||||
|
della cartella ricetta (b64): disegno.dxf e calibrazione.json.
|
||||||
|
"""
|
||||||
|
if not (px_per_mm > 0 and math.isfinite(px_per_mm)):
|
||||||
|
raise CostruttoreError("px_per_mm deve essere > 0")
|
||||||
|
if not (band_mm > 0 and math.isfinite(band_mm)):
|
||||||
|
raise CostruttoreError("band_mm deve essere > 0")
|
||||||
|
if not (0 < min_points_pct <= 100):
|
||||||
|
raise CostruttoreError("min_points_pct deve stare in (0, 100]")
|
||||||
|
if polarity not in _POLARITIES:
|
||||||
|
raise CostruttoreError(f"polarity deve essere una di {list(_POLARITIES)}")
|
||||||
|
if not dxf_bytes:
|
||||||
|
raise CostruttoreError("DXF vuoto")
|
||||||
|
|
||||||
|
frame = _gray_u8(gray)
|
||||||
|
calibration_bytes = json.dumps({"px_per_mm": px_per_mm}).encode("utf-8")
|
||||||
|
dxf_sha = hashlib.sha256(dxf_bytes).hexdigest()
|
||||||
|
cal_sha = hashlib.sha256(calibration_bytes).hexdigest()
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
folder = Path(tmp)
|
||||||
|
dxf_path = folder / DXF_FILE
|
||||||
|
dxf_path.write_bytes(dxf_bytes)
|
||||||
|
(folder / CALIBRATION_FILE).write_bytes(calibration_bytes)
|
||||||
|
|
||||||
|
# DXF leggibile? (ezdxf solleva su file corrotto/non DXF)
|
||||||
|
from visionsuite.io.dxf import read_xld_dxf
|
||||||
|
try:
|
||||||
|
drawing = list(read_xld_dxf(str(dxf_path), sampling_distance=0.1,
|
||||||
|
scale=px_per_mm, flip_y=True))
|
||||||
|
except Exception as exc: # noqa: BLE001 - qualunque errore di lettura = DXF illeggibile
|
||||||
|
raise CostruttoreError(f"DXF illeggibile: {type(exc).__name__}: {exc}") from exc
|
||||||
|
if not drawing:
|
||||||
|
raise CostruttoreError("DXF illeggibile: nessuna entita' geometrica")
|
||||||
|
|
||||||
|
reference_pose = _locate_reference(frame, polarity)
|
||||||
|
anchors = _candidate_anchors(frame, dxf_path, px_per_mm, polarity) # #86
|
||||||
|
if not anchors:
|
||||||
|
raise CostruttoreError("disegno non sovrapponibile al pezzo (ICP fallito)")
|
||||||
|
|
||||||
|
# Verita' = eseguire davvero la ricetta sul riferimento: vince il maggior points_in_pct
|
||||||
|
best: tuple[float, dict, RunOutcome] | None = None
|
||||||
|
for anchor in anchors:
|
||||||
|
graph = _graph(dxf_sha, cal_sha, band_mm, min_points_pct, polarity, reference_pose, anchor)
|
||||||
|
outcome = _run(frame, graph, px_per_mm, folder)
|
||||||
|
pct = outcome.outputs.get("dxf.points_in_pct")
|
||||||
|
if pct is not None and (best is None or pct > best[0]):
|
||||||
|
best = (pct, graph, outcome)
|
||||||
|
if best is None:
|
||||||
|
raise CostruttoreError(
|
||||||
|
"disegno non sovrapponibile al pezzo: il confronto non ha prodotto punti"
|
||||||
|
)
|
||||||
|
_, graph, outcome = best
|
||||||
|
|
||||||
|
# solo le quattro uscite del contratto (n_points resta fuori)
|
||||||
|
outputs = {k: v for k, v in outcome.outputs.items() if k in _OUTPUT_KEYS}
|
||||||
|
return {
|
||||||
|
"graph": graph,
|
||||||
|
"files": {DXF_FILE: _b64(dxf_bytes), CALIBRATION_FILE: _b64(calibration_bytes)},
|
||||||
|
"outputs": outputs,
|
||||||
|
"overlay": outcome.overlay,
|
||||||
|
"outcome": outcome.outcome,
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""Costruttore di ricette dal campo: immagine + DXF + scala -> ricetta v2."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Come test_runner.py: l'albero vision richiede Python >=3.13; su 3.11 e' skipped.
|
||||||
|
pytest.importorskip(
|
||||||
|
"visionsuite.task",
|
||||||
|
reason=(
|
||||||
|
"the vision tree requires Python >=3.13; run it with "
|
||||||
|
"`uv run --python 3.13 --extra vision --extra dev pytest src/vision/tests`"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from src.vision.costruttori import (
|
||||||
|
CALIBRATION_FILE,
|
||||||
|
DXF_FILE,
|
||||||
|
CostruttoreError,
|
||||||
|
_candidate_anchors,
|
||||||
|
_maschera_pezzo,
|
||||||
|
build_dxf_compare,
|
||||||
|
)
|
||||||
|
from src.vision.runner import run_graph
|
||||||
|
|
||||||
|
SAMPLE_DXF = (
|
||||||
|
Path(__file__).resolve().parents[3]
|
||||||
|
/ "vendor/visionsuite/packages/vs-dxf/tests/fixtures/sample.dxf"
|
||||||
|
)
|
||||||
|
DXF_WIDTH_MM, DXF_HEIGHT_MM, DXF_HOLE_R_MM = 200.0, 100.0, 10.0
|
||||||
|
PX_PER_MM = 4.0
|
||||||
|
|
||||||
|
|
||||||
|
def a_scene(angle_deg: float = 0.0, shift_px: tuple[float, float] = (0.0, 0.0),
|
||||||
|
margin_mm: float = 60.0) -> np.ndarray:
|
||||||
|
"""Il pezzo del sample.dxf (scuro su bianco) a 4 px/mm, ruotato e traslato."""
|
||||||
|
w = int((DXF_WIDTH_MM + 2 * margin_mm) * PX_PER_MM)
|
||||||
|
h = int((DXF_HEIGHT_MM + 2 * margin_mm) * PX_PER_MM)
|
||||||
|
img = np.full((h, w), 255, np.uint8)
|
||||||
|
cx, cy = w // 2, h // 2
|
||||||
|
hw, hh = int(DXF_WIDTH_MM * PX_PER_MM / 2), int(DXF_HEIGHT_MM * PX_PER_MM / 2)
|
||||||
|
cv2.rectangle(img, (cx - hw, cy - hh), (cx + hw, cy + hh), 0, -1)
|
||||||
|
cv2.circle(img, (cx, cy), int(DXF_HOLE_R_MM * PX_PER_MM), 255, -1)
|
||||||
|
if angle_deg or any(shift_px):
|
||||||
|
m = cv2.getRotationMatrix2D((cx, cy), angle_deg, 1.0) # >0 = antiorario a video
|
||||||
|
m[0, 2] += shift_px[0]
|
||||||
|
m[1, 2] += shift_px[1]
|
||||||
|
img = cv2.warpAffine(img, m, (w, h), flags=cv2.INTER_LINEAR, borderValue=255)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ ricetta DXF
|
||||||
|
|
||||||
|
def test_ricetta_dxf_gira_su_riferimento_e_su_scena_ruotata(tmp_path):
|
||||||
|
dxf = SAMPLE_DXF.read_bytes()
|
||||||
|
risultato = build_dxf_compare(a_scene(0.0), dxf, px_per_mm=PX_PER_MM, band_mm=2.0)
|
||||||
|
|
||||||
|
assert risultato["outputs"]["dxf.points_in_pct"] > 95.0 # sul riferimento
|
||||||
|
assert set(risultato["outputs"]) == {
|
||||||
|
"dxf.points_in_pct", "dxf.distance_avg", "dxf.distance_min", "dxf.distance_max"}
|
||||||
|
assert set(risultato["files"]) == {DXF_FILE, CALIBRATION_FILE}
|
||||||
|
assert json.loads(base64.b64decode(risultato["files"][CALIBRATION_FILE])) == {
|
||||||
|
"px_per_mm": PX_PER_MM}
|
||||||
|
assert risultato["overlay"] and risultato["outcome"] is not None
|
||||||
|
|
||||||
|
graph = risultato["graph"]
|
||||||
|
assert graph["schema_version"] == 2
|
||||||
|
# path come da contratto; lo sha256 lo esige vs-task per ogni file in meta
|
||||||
|
assert graph["meta"]["dxf"]["disegno"]["path"] == "disegno.dxf"
|
||||||
|
assert graph["meta"]["calibration"]["path"] == "calibrazione.json"
|
||||||
|
assert len(graph["meta"]["dxf"]["disegno"]["sha256"]) == 64
|
||||||
|
pezzo, dxf_tool = graph["tools"]
|
||||||
|
assert (pezzo["id"], pezzo["kind"], pezzo["name"]) == ("pezzo", "LocateBlob", "Pezzo")
|
||||||
|
assert (dxf_tool["id"], dxf_tool["kind"], dxf_tool["name"]) == ("dxf", "CompareDxf", "Confronto DXF")
|
||||||
|
assert dxf_tool["links"] == {"align": {"tool": "pezzo", "property": "poses"}}
|
||||||
|
|
||||||
|
# La cartella ricetta come la scriverebbe il backend, poi la scena ruotata di 5 gradi
|
||||||
|
for nome, b64 in risultato["files"].items():
|
||||||
|
(tmp_path / nome).write_bytes(base64.b64decode(b64))
|
||||||
|
ruotata = a_scene(5.0, shift_px=(30.0, 20.0))
|
||||||
|
esito = run_graph(ruotata, graph, calibration={"px_per_mm": PX_PER_MM}, recipe_dir=tmp_path)
|
||||||
|
assert not esito.failures, esito.failures
|
||||||
|
assert esito.outputs["dxf.points_in_pct"] > 95.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_ricetta_dxf_con_riferimento_gia_ruotato(tmp_path):
|
||||||
|
# il riferimento non e' a 0 gradi: la ricetta si costruisce lo stesso
|
||||||
|
risultato = build_dxf_compare(a_scene(20.0, shift_px=(-15.0, 10.0)),
|
||||||
|
SAMPLE_DXF.read_bytes(), PX_PER_MM, band_mm=2.0)
|
||||||
|
assert risultato["outputs"]["dxf.points_in_pct"] > 95.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_immagine_senza_pezzo_solleva():
|
||||||
|
bianca = np.full((600, 800), 255, np.uint8)
|
||||||
|
with pytest.raises(CostruttoreError, match="pezzo"):
|
||||||
|
build_dxf_compare(bianca, SAMPLE_DXF.read_bytes(), PX_PER_MM)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dxf_illeggibile_solleva():
|
||||||
|
with pytest.raises(CostruttoreError, match="DXF"):
|
||||||
|
build_dxf_compare(a_scene(), b"questo non e' un dxf", PX_PER_MM)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("kwargs", [
|
||||||
|
{"px_per_mm": 0.0}, {"px_per_mm": PX_PER_MM, "band_mm": 0.0},
|
||||||
|
{"px_per_mm": PX_PER_MM, "min_points_pct": 0.0},
|
||||||
|
{"px_per_mm": PX_PER_MM, "min_points_pct": 101.0},
|
||||||
|
{"px_per_mm": PX_PER_MM, "polarity": "grigio"},
|
||||||
|
])
|
||||||
|
def test_parametri_ricetta_invalidi(kwargs):
|
||||||
|
with pytest.raises(CostruttoreError):
|
||||||
|
build_dxf_compare(a_scene(), SAMPLE_DXF.read_bytes(), **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_maschera_pezzo_tiene_solo_il_blob_staccato_dal_bordo():
|
||||||
|
# #86: pezzo scuro staccato + rettangoli scuri attaccati al bordo + angolo nero
|
||||||
|
img = np.full((500, 600), 230, np.uint8)
|
||||||
|
img[200:300, 250:400] = 20 # il pezzo
|
||||||
|
img[0:60, 100:220] = 15 # attaccato al bordo alto
|
||||||
|
img[420:500, 300:420] = 15 # attaccato al bordo basso
|
||||||
|
img[0:80, 0:80] = 0 # angolo nero (borderValue=0 del raddrizzamento)
|
||||||
|
out = _maschera_pezzo(img, "dark", px_per_mm=4.0)
|
||||||
|
assert (out[200:300, 250:400] == 20).all() # il pezzo resta
|
||||||
|
assert (out[0:60, 100:220] == 255).all() # i rettangoli sono sfondo
|
||||||
|
assert (out[420:500, 300:420] == 255).all()
|
||||||
|
assert (out[0:80, 0:80] == 255).all() # angolo nero cancellato
|
||||||
|
|
||||||
|
|
||||||
|
def test_maschera_pezzo_senza_blob_staccato_solleva():
|
||||||
|
img = np.full((200, 200), 230, np.uint8)
|
||||||
|
img[:, 0:50] = 10 # solo una fascia attaccata al bordo
|
||||||
|
with pytest.raises(CostruttoreError, match="staccato dal bordo"):
|
||||||
|
_maschera_pezzo(img, "dark")
|
||||||
|
|
||||||
|
|
||||||
|
def test_ricetta_dxf_icp_fallito_riporta_il_motivo(monkeypatch, tmp_path):
|
||||||
|
# #86: l'errore "ICP fallito" porta il messaggio degli orientamenti caduti
|
||||||
|
from visionsuite.dxf.compare import DXFCompare
|
||||||
|
|
||||||
|
def boom(self, *a, **k):
|
||||||
|
raise ValueError("negative dimensions are not allowed")
|
||||||
|
|
||||||
|
monkeypatch.setattr(DXFCompare, "compare", boom)
|
||||||
|
with pytest.raises(CostruttoreError, match="ICP fallito.*negative dimensions"):
|
||||||
|
_candidate_anchors(a_scene(), SAMPLE_DXF, PX_PER_MM)
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -783,6 +783,33 @@ woff = [
|
|||||||
{ name = "zopfli" },
|
{ name = "zopfli" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "genicam"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/39/97c9a39fde61441731a383bde6a86b079a5a9d1d22c6d9eb16556b77016e/genicam-1.6.0-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:2a5bfeb3b9796cf6667192c2957f8ea7ec240ff42e76cc6e3e05d1d0230bf68a", size = 2556729, upload-time = "2026-09-01T08:26:57.558Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/1b/bf993221a155d2fd35c8201be97cd0376e005bf1cc82dec27105a712d3b1/genicam-1.6.0-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:4207d7ccee9deb755ad372c0b473fd2f23e4e42da61e06e4a7873d199b883a0f", size = 2604157, upload-time = "2026-09-01T08:26:58.847Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/92/68c0033f076ad1defed2e6bf1cec8fc720a24622fb0389013b058e841234/genicam-1.6.0-cp311-cp311-manylinux2014_armv7l.whl", hash = "sha256:a750ffb11194f02740531bc0f62bf7013d39f2eea65f5e283c7811ab757fc987", size = 2190252, upload-time = "2026-09-01T08:27:00.126Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/b3/252e5d64022984de4a78220c456f1f91742dbd7bb02d5b3926ed4c228015/genicam-1.6.0-cp311-cp311-manylinux2014_x86_64.whl", hash = "sha256:589b1152f22ca766f8b5e1dff9c1b4006c2eb7f5ee33152619ef6ece8a5a3f47", size = 2800290, upload-time = "2026-09-01T08:27:01.364Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/ec/19092034d0f66646d97b85e1549b6b4e2448f5287bb3a76b5a8cd677ba4a/genicam-1.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:ef1d25c7e9823689dbfb9e08d8fdb46e0f05982ca9c27d1eeacc1aa8fcd69f6e", size = 1802065, upload-time = "2026-09-01T08:27:02.555Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/84/0dc939a435deef267d82bc6aaa4b7ed9c89510766454640622608d0e7ce5/genicam-1.6.0-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:d0becabb469d0f51f9d4820d655d2788c620a8ee1c8abcfa4703cac87ba27f53", size = 2557117, upload-time = "2026-09-01T08:27:03.87Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/68/499657bf3e5111fe1c87e3364c2ae6b59fe912d7184fccf591eafd762ceb/genicam-1.6.0-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:3a1a16b89fec69d52142781f280ce2c01a4c1422da1cd08893ed1cd630d11c30", size = 2607960, upload-time = "2026-09-01T08:27:05.149Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8f/ac/3b8496c446715008e77dda7439735c428f163aab0a3f643352407a259a52/genicam-1.6.0-cp312-cp312-manylinux2014_armv7l.whl", hash = "sha256:b65beca4b425442922a2f2beefae1e3109ce166cfd726fcd9d2ac59ff1d57e24", size = 2189851, upload-time = "2026-09-01T08:27:06.516Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/9c/49d68f5055e970e4e4d834bc3df686a01f7bd82e070888fc7ad2c86e2eaf/genicam-1.6.0-cp312-cp312-manylinux2014_x86_64.whl", hash = "sha256:83da90404da189a87273864f2c97c761c1807fc6fc532fabd3bf50c0ae28f8ae", size = 2799503, upload-time = "2026-09-01T08:27:07.963Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/4a/e643cac4031ad7b1780ab19bcdeb6b0545e829b2ee10ea37ca1185d9417d/genicam-1.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d2bb3f39443c5bc248fcbde83a3a973370ca43588a7c1ded7d7324d7d9427d3", size = 1802200, upload-time = "2026-09-01T08:27:09.766Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/f1/b5f641ad64c296462ab2ca314b8a471827142b949606ebe8b69bff7e6a08/genicam-1.6.0-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:f5c35275014f4793b34a21ebe8bbcb269a87b5d1bf129daf2818c6160f8758c9", size = 2557108, upload-time = "2026-09-01T08:27:11.211Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/59/86ae5cbc1a89f70c2b3b906e692a46b977e9230664781cd5a304012f6b20/genicam-1.6.0-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:f4d6496660507b32a7f6ef05f5344bcd305ea3e2158a41bf53169663d7a72e70", size = 2608137, upload-time = "2026-09-01T08:27:12.405Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/37/25a84938b5ec34fc6123006a3f4173fead4fde3b460b516749a8ae8c1b02/genicam-1.6.0-cp313-cp313-manylinux2014_armv7l.whl", hash = "sha256:efe646df5e8ca76208c6621e8a5dbea3e9909641801787993ea2d7ec4a704c09", size = 2189839, upload-time = "2026-09-01T08:27:13.834Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/eb/37363beb1880674ff0976ff7607e2e045933c6388d08d98bc0b512df941c/genicam-1.6.0-cp313-cp313-manylinux2014_x86_64.whl", hash = "sha256:a342980e80735b7b1e4f18f6c33d64eccc3e95887dd72f302409f1296bef3dac", size = 2799503, upload-time = "2026-09-01T08:27:15.253Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/38/895e9effffc858ff06996a9eed28decdc9b5b2176b2cfd82df8c4f65bdec/genicam-1.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:740156c5fa8d82330d36314112f4b755e8333c16f82792174f1a2a2e150519ae", size = 1802243, upload-time = "2026-09-01T08:27:16.576Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/26/98a864061d25e78b96b7d621814b399a78fb7603ae6a6ac871648587bc49/genicam-1.6.0-cp314-cp314-macosx_13_0_arm64.whl", hash = "sha256:064a87188b6102bac03a8ab1ccff5280e8fa4cf6378448e6286ed6f4cc9f7a64", size = 2557508, upload-time = "2026-09-01T08:27:17.763Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/32/2ef1193d27bb375e3d9822be883e987d75203fcb417bafa96fd77fe8be18/genicam-1.6.0-cp314-cp314-manylinux2014_aarch64.whl", hash = "sha256:3dcf4af1fabb1cee06cac0bed6560f0e49180dab1e078501949eaf07596736c3", size = 2609251, upload-time = "2026-09-01T08:27:19.161Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/06/ea8b6c2f4536cd8f54961040edae1ad9ff8a3dced2081a2377dac600bb73/genicam-1.6.0-cp314-cp314-manylinux2014_armv7l.whl", hash = "sha256:e3f9ff542bbd2fe389e1a94e4a694ec34f94ab378247c5f7e761b78ff5891d24", size = 2189734, upload-time = "2026-09-01T08:27:20.541Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/ff/a8c90b9a7d99fa6008437491f1198264c22d49572a0013a05bc1c579e034/genicam-1.6.0-cp314-cp314-manylinux2014_x86_64.whl", hash = "sha256:d1e12e276eab6dbe8dec37e060ea5a8cc5758cc8c3502a048782e38a0451517d", size = 2799526, upload-time = "2026-09-01T08:27:21.77Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/18/c6f0b7f3da480aac0c86d104664e9cf144675986cc4e3f108c909bec0ddf/genicam-1.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:7630b7844b273080f21f01881c56667cc68521186b75dd15798f5dee1f8f1037", size = 1846842, upload-time = "2026-09-01T08:27:23.18Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "greenlet"
|
name = "greenlet"
|
||||||
version = "3.4.0"
|
version = "3.4.0"
|
||||||
@@ -861,6 +888,19 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "harvesters"
|
||||||
|
version = "1.4.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "genicam" },
|
||||||
|
{ name = "numpy" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d3/18/ee45d896fee51dee2e35df9d371e2d213ed4488ddea4dd4bd1df6f92a5fb/harvesters-1.4.3.tar.gz", hash = "sha256:c237dd402735a66ebd570865113a3e80ae19161a21706aed80691627a8cb7da2", size = 104391, upload-time = "2024-05-16T17:09:41.805Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/ab/295e5ec52cc2859ad8fa0d56f953cefa26f3e1eb28cc937162d7df7df872/harvesters-1.4.3-py3-none-any.whl", hash = "sha256:74994cedc941051b9ce9527c5b336e611589a1bc209bc03186c49102a55f1f1f", size = 100253, upload-time = "2024-05-16T17:09:27.933Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httpcore"
|
name = "httpcore"
|
||||||
version = "1.0.9"
|
version = "1.0.9"
|
||||||
@@ -1113,6 +1153,12 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/d0/69/f24d3d1c38ad69e256138b4ec2452a8c7cf66be49dc214771ae99dd4f0a0/narwhals-2.20.0-py3-none-any.whl", hash = "sha256:16e750ea5507d4ba6e8d03455b5f93a535e0405976561baea235bca5dc9f475d", size = 449373, upload-time = "2026-04-20T12:11:43.596Z" },
|
{ url = "https://files.pythonhosted.org/packages/d0/69/f24d3d1c38ad69e256138b4ec2452a8c7cf66be49dc214771ae99dd4f0a0/narwhals-2.20.0-py3-none-any.whl", hash = "sha256:16e750ea5507d4ba6e8d03455b5f93a535e0405976561baea235bca5dc9f475d", size = 449373, upload-time = "2026-04-20T12:11:43.596Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nuitka"
|
||||||
|
version = "4.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/fa/5f/ba7cb858da0c98c24f85c54454a5ea18f2b784f658a91531fa051b2f2ee9/nuitka-4.3.tar.gz", hash = "sha256:8b102c6bf30d9504e82e69674d396972729092b25cefa680e8fd05678fdfa30b", size = 4766934, upload-time = "2026-10-10T11:13:15.577Z" }
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "numba"
|
name = "numba"
|
||||||
version = "0.67.0"
|
version = "0.67.0"
|
||||||
@@ -1238,6 +1284,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/21/f0/9fa6e85cb10c8eb36a0222d27e50fe381b86ce49a55446bf39f491727564/opencv_python-5.0.0.93-cp37-abi3-win_amd64.whl", hash = "sha256:f90ba04b8f73bc5c3814037699739f0156f597338a98f05956c684e7c3ca10d2", size = 44000345, upload-time = "2026-07-02T05:49:54.971Z" },
|
{ url = "https://files.pythonhosted.org/packages/21/f0/9fa6e85cb10c8eb36a0222d27e50fe381b86ce49a55446bf39f491727564/opencv_python-5.0.0.93-cp37-abi3-win_amd64.whl", hash = "sha256:f90ba04b8f73bc5c3814037699739f0156f597338a98f05956c684e7c3ca10d2", size = 44000345, upload-time = "2026-07-02T05:49:54.971Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ordered-set"
|
||||||
|
version = "4.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/4c/ca/bfac8bc689799bcca4157e0e0ced07e70ce125193fc2e166d2e685b7e2fe/ordered-set-4.1.0.tar.gz", hash = "sha256:694a8e44c87657c59292ede72891eb91d34131f6531463aab3009191c77364a8", size = 12826, upload-time = "2022-01-26T14:38:56.6Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/55/af02708f230eb77084a299d7b08175cff006dea4f2721074b92cdb0296c0/ordered_set-4.1.0-py3-none-any.whl", hash = "sha256:046e1132c71fcf3330438a539928932caf51ddbc582496833e23de611de14562", size = 7634, upload-time = "2022-01-26T14:38:48.677Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "orjson"
|
name = "orjson"
|
||||||
version = "3.11.8"
|
version = "3.11.8"
|
||||||
@@ -2043,6 +2098,18 @@ vision-worker = [
|
|||||||
{ name = "vs-tools", extra = ["dxf", "locate"], marker = "python_full_version >= '3.13'" },
|
{ name = "vs-tools", extra = ["dxf", "locate"], marker = "python_full_version >= '3.13'" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[package.dev-dependencies]
|
||||||
|
windows = [
|
||||||
|
{ name = "aiosqlite" },
|
||||||
|
{ name = "nuitka" },
|
||||||
|
{ name = "ordered-set" },
|
||||||
|
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "vs-camera", extra = ["genicam"], marker = "python_full_version >= '3.13'" },
|
||||||
|
{ name = "vs-license", marker = "python_full_version >= '3.13'" },
|
||||||
|
{ name = "waitress" },
|
||||||
|
{ name = "zstandard" },
|
||||||
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "aiosqlite", marker = "extra == 'dev'", specifier = ">=0.20.0" },
|
{ name = "aiosqlite", marker = "extra == 'dev'", specifier = ">=0.20.0" },
|
||||||
@@ -2087,6 +2154,18 @@ requires-dist = [
|
|||||||
]
|
]
|
||||||
provides-extras = ["server", "client", "dev", "vision", "vision-worker"]
|
provides-extras = ["server", "client", "dev", "vision", "vision-worker"]
|
||||||
|
|
||||||
|
[package.metadata.requires-dev]
|
||||||
|
windows = [
|
||||||
|
{ name = "aiosqlite", specifier = ">=0.20.0" },
|
||||||
|
{ name = "nuitka", specifier = ">=2.5" },
|
||||||
|
{ name = "ordered-set", specifier = ">=4.1" },
|
||||||
|
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "vs-camera", extras = ["genicam"], marker = "python_full_version >= '3.13'", editable = "vendor/visionsuite/packages/vs-camera" },
|
||||||
|
{ name = "vs-license", marker = "python_full_version >= '3.13'", editable = "vendor/visionsuite/packages/vs-license" },
|
||||||
|
{ name = "waitress", specifier = ">=3.0.0" },
|
||||||
|
{ name = "zstandard", specifier = ">=0.22" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinycss2"
|
name = "tinycss2"
|
||||||
version = "1.5.1"
|
version = "1.5.1"
|
||||||
@@ -2132,6 +2211,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tzdata"
|
||||||
|
version = "2026.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d9/68/f1b440335057bfce71b6e50a9d09445aa2ecbd08359a337976627b8409e7/tzdata-2026.5.tar.gz", hash = "sha256:8cc73c0a0bfca7dbfa59235d60b2eff82231dee33f53d206db1acd9173cfc0a7", size = 200404, upload-time = "2026-10-03T09:23:14.143Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/21/1e5995a1c920cce14e4bffae20c665ec10e7ed03ab25e006cd741092b718/tzdata-2026.5-py2.py3-none-any.whl", hash = "sha256:b683bd1b6659ddcd810ff02ad09ba821d4bf1065072805063eb35c49617905ac", size = 347996, upload-time = "2026-10-03T09:23:12.535Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "urllib3"
|
name = "urllib3"
|
||||||
version = "2.6.3"
|
version = "2.6.3"
|
||||||
@@ -2230,6 +2318,31 @@ requires-dist = [
|
|||||||
]
|
]
|
||||||
provides-extras = ["dev", "fast", "dxf"]
|
provides-extras = ["dev", "fast", "dxf"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vs-camera"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { editable = "vendor/visionsuite/packages/vs-camera" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "numpy" },
|
||||||
|
{ name = "opencv-python" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
genicam = [
|
||||||
|
{ name = "harvesters" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "flask", marker = "extra == 'dev'", specifier = ">=3.0" },
|
||||||
|
{ name = "harvesters", marker = "extra == 'genicam'", specifier = ">=1.4" },
|
||||||
|
{ name = "netifaces", marker = "extra == 'rete'", specifier = ">=0.11" },
|
||||||
|
{ name = "numpy", specifier = ">=1.24" },
|
||||||
|
{ name = "opencv-python", specifier = ">=4.8" },
|
||||||
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
|
||||||
|
]
|
||||||
|
provides-extras = ["genicam", "rete", "dev"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "vs-dxf"
|
name = "vs-dxf"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -2256,6 +2369,21 @@ requires-dist = [
|
|||||||
]
|
]
|
||||||
provides-extras = ["dev"]
|
provides-extras = ["dev"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vs-license"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { editable = "vendor/visionsuite/packages/vs-license" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "cryptography" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "cryptography", specifier = ">=42" },
|
||||||
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7" },
|
||||||
|
]
|
||||||
|
provides-extras = ["dev"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "vs-measure"
|
name = "vs-measure"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -2360,6 +2488,15 @@ requires-dist = [
|
|||||||
]
|
]
|
||||||
provides-extras = ["locate", "dxf", "dev"]
|
provides-extras = ["locate", "dxf", "dev"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "waitress"
|
||||||
|
version = "3.0.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/bf/cb/04ddb054f45faa306a230769e868c28b8065ea196891f09004ebace5b184/waitress-3.0.2.tar.gz", hash = "sha256:682aaaf2af0c44ada4abfb70ded36393f0e307f4ab9456a215ce0020baefc31f", size = 179901, upload-time = "2024-11-16T20:02:35.195Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8d/57/a27182528c90ef38d82b636a11f606b0cbb0e17588ed205435f8affe3368/waitress-3.0.2-py3-none-any.whl", hash = "sha256:c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e", size = 56232, upload-time = "2024-11-16T20:02:33.858Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "watchfiles"
|
name = "watchfiles"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
@@ -2576,3 +2713,77 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/23/f0/ad6e26aa06943ce9f1be4ae6738513a7b69d8ea1f3b13e46009a249a3f73/zopfli-0.4.1-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb136a74d14a4ecfae29cb0fdecece58a6c115abc9a74c12bc6ac62e80f229d7", size = 124371, upload-time = "2026-02-13T14:17:24.976Z" },
|
{ url = "https://files.pythonhosted.org/packages/23/f0/ad6e26aa06943ce9f1be4ae6738513a7b69d8ea1f3b13e46009a249a3f73/zopfli-0.4.1-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb136a74d14a4ecfae29cb0fdecece58a6c115abc9a74c12bc6ac62e80f229d7", size = 124371, upload-time = "2026-02-13T14:17:24.976Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7b/36/3c15d564db6dfdd740919b205bdb69be75113e9919c422cde658e6d013c0/zopfli-0.4.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2f992ac7d83cbddd889e1813ace576cbc91a05d5d7a0a21b366e2e5f492e7707", size = 102199, upload-time = "2026-02-13T14:17:26.246Z" },
|
{ url = "https://files.pythonhosted.org/packages/7b/36/3c15d564db6dfdd740919b205bdb69be75113e9919c422cde658e6d013c0/zopfli-0.4.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2f992ac7d83cbddd889e1813ace576cbc91a05d5d7a0a21b366e2e5f492e7707", size = 102199, upload-time = "2026-02-13T14:17:26.246Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zstandard"
|
||||||
|
version = "0.25.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/83/c3ca27c363d104980f1c9cee1101cc8ba724ac8c28a033ede6aab89585b1/zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c", size = 795254, upload-time = "2025-09-14T22:16:26.137Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/4d/e66465c5411a7cf4866aeadc7d108081d8ceba9bc7abe6b14aa21c671ec3/zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f", size = 640559, upload-time = "2025-09-14T22:16:27.973Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/56/354fe655905f290d3b147b33fe946b0f27e791e4b50a5f004c802cb3eb7b/zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431", size = 5348020, upload-time = "2025-09-14T22:16:29.523Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/13/2b7ed68bd85e69a2069bcc72141d378f22cae5a0f3b353a2c8f50ef30c1b/zstandard-0.25.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a", size = 5058126, upload-time = "2025-09-14T22:16:31.811Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/dd/fdaf0674f4b10d92cb120ccff58bbb6626bf8368f00ebfd2a41ba4a0dc99/zstandard-0.25.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc", size = 5405390, upload-time = "2025-09-14T22:16:33.486Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/67/354d1555575bc2490435f90d67ca4dd65238ff2f119f30f72d5cde09c2ad/zstandard-0.25.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6", size = 5452914, upload-time = "2025-09-14T22:16:35.277Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/1f/e9cfd801a3f9190bf3e759c422bbfd2247db9d7f3d54a56ecde70137791a/zstandard-0.25.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072", size = 5559635, upload-time = "2025-09-14T22:16:37.141Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/21/88/5ba550f797ca953a52d708c8e4f380959e7e3280af029e38fbf47b55916e/zstandard-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277", size = 5048277, upload-time = "2025-09-14T22:16:38.807Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/c0/ca3e533b4fa03112facbe7fbe7779cb1ebec215688e5df576fe5429172e0/zstandard-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313", size = 5574377, upload-time = "2025-09-14T22:16:40.523Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/9b/3fb626390113f272abd0799fd677ea33d5fc3ec185e62e6be534493c4b60/zstandard-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097", size = 4961493, upload-time = "2025-09-14T22:16:43.3Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/d3/23094a6b6a4b1343b27ae68249daa17ae0651fcfec9ed4de09d14b940285/zstandard-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778", size = 5269018, upload-time = "2025-09-14T22:16:45.292Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/a7/bb5a0c1c0f3f4b5e9d5b55198e39de91e04ba7c205cc46fcb0f95f0383c1/zstandard-0.25.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065", size = 5443672, upload-time = "2025-09-14T22:16:47.076Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/22/503347aa08d073993f25109c36c8d9f029c7d5949198050962cb568dfa5e/zstandard-0.25.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa", size = 5822753, upload-time = "2025-09-14T22:16:49.316Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/be/94267dc6ee64f0f8ba2b2ae7c7a2df934a816baaa7291db9e1aa77394c3c/zstandard-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7", size = 5366047, upload-time = "2025-09-14T22:16:51.328Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/a3/732893eab0a3a7aecff8b99052fecf9f605cf0fb5fb6d0290e36beee47a4/zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4", size = 436484, upload-time = "2025-09-14T22:16:55.005Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/a3/c6155f5c1cce691cb80dfd38627046e50af3ee9ddc5d0b45b9b063bfb8c9/zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2", size = 506183, upload-time = "2025-09-14T22:16:52.753Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/3e/8945ab86a0820cc0e0cdbf38086a92868a9172020fdab8a03ac19662b0e5/zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137", size = 462533, upload-time = "2025-09-14T22:16:53.878Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" },
|
||||||
|
]
|
||||||
|
|||||||
Vendored
+1
-1
Submodule vendor/visionsuite updated: 4c178cf758...06b549a25d
Reference in New Issue
Block a user