Compare commits
185 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1dd4b0a20c | |||
| 271ce73d09 | |||
| 1bd6c6b011 | |||
| d8bca535ac | |||
| 14257304f8 | |||
| 5738920c9c | |||
| 452862e0f3 | |||
| 999f47092d | |||
| 6441a92062 | |||
| 8c361c14ae | |||
| 5897a9e5df | |||
| f44b578d5b | |||
| c5be9a2bb4 | |||
| 17ea010f7e | |||
| 1d6d74b2e4 | |||
| 2f0a5aaf1e | |||
| e96fbb73c2 | |||
| f3bacc8238 | |||
| e743363a62 | |||
| d48a8a1076 | |||
| 8ef61a1285 | |||
| 84fc1a08f7 | |||
| c904800e94 | |||
| 20c0a47c00 | |||
| 269bbc03ca | |||
| 815d49d699 | |||
| 43ef22079d | |||
| b01bba536a | |||
| 17344aae13 | |||
| fd3384f08c | |||
| 08aefa9888 | |||
| 06f308be6f | |||
| 93afd6187c | |||
| 8217f46d47 | |||
| d4802fd1a3 | |||
| ea64f3c524 | |||
| 58baed19e2 | |||
| 25b0741003 | |||
| df67a32a82 | |||
| 5723f055c4 | |||
| 4485803122 | |||
| acd94a9d33 | |||
| 88b3f202b1 | |||
| 4a11d2595f | |||
| b46ccba1ee | |||
| 7a1df0bca4 | |||
| 198c6c63c4 | |||
| 04b81ad2f2 | |||
| 45c9e6c3f3 | |||
| ca6cd0cab1 | |||
| 34c251aa90 | |||
| c70d29d6d7 | |||
| 96aca975d0 | |||
| b3c56ef5b5 | |||
| 9111eb2741 | |||
| b46a70347f | |||
| 155abfd15c | |||
| 2c2d601495 | |||
| b2e3867370 | |||
| cab419bdc7 | |||
| 249999fa1e | |||
| a3e032d000 | |||
| 57376556d2 | |||
| 7cb877b11b | |||
| 08616aa1fa | |||
| 02f1481fe5 | |||
| 34e1b042f5 | |||
| bdef1f1cd8 | |||
| 39b9ad77eb | |||
| 12632323e8 | |||
| 839a61e479 | |||
| 9b8e729927 | |||
| 6080e6aebc | |||
| d5db5f3014 | |||
| 1a19bb6679 | |||
| 20f33234a8 | |||
| 711abc96df | |||
| 77419f9ebd | |||
| 4791c34205 | |||
| e582df7962 | |||
| a7b91b5508 | |||
| addf5a2598 | |||
| bdb1e8c8b9 | |||
| ebaafbe6e9 | |||
| 351153e78a | |||
| 842ceca007 | |||
| b78f2422d8 | |||
| 444dc0a6d1 | |||
| 27d7c1e832 | |||
| 4b1b23cfb6 | |||
| 4b57efc73c | |||
| 7c03f53000 | |||
| 67337fa232 | |||
| 661b782bad | |||
| 25f8b4d609 | |||
| b98d1f15f4 | |||
| 8f9731839a | |||
| 552f96bb47 | |||
| 71daab184f | |||
| 9beaf2ed67 | |||
| 800ec41254 | |||
| 9c45a51770 | |||
| 4a48af2ae3 | |||
| b879563738 | |||
| f504c86491 | |||
| 632fd51050 | |||
| 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 | |||
| 6bc6c0c0d5 | |||
| e5989c9d23 | |||
| 0af6692786 | |||
| 36074ab2f7 | |||
| fd1bbce8aa | |||
| 1453b31cee | |||
| 192d4b590c | |||
| b9f43a0ede | |||
| 5bf374aadd | |||
| 74fe5665b5 | |||
| be904bac07 | |||
| 011c08c9d8 | |||
| 82ad88538d | |||
| bafc776ef7 | |||
| 8fcb4796d5 | |||
| 8d7fa9a12a | |||
| c911449b7c | |||
| ac8604eff5 |
@@ -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,9 @@ 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
|
||||||
|
|
||||||
|
# #109: scritto da packaging/windows/sviluppo.ps1 (sha del submodule), non va versionato
|
||||||
|
/vision-engine-version.txt
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
[submodule "vendor/visionsuite"]
|
[submodule "vendor/visionsuite"]
|
||||||
path = vendor/visionsuite
|
path = vendor/visionsuite
|
||||||
url = ssh://git@git.tielogic.xyz:222/Adriano/visionsuite.git
|
url = ssh://git@git.tielogic.xyz:222/Adriano/TieVision-Core.git
|
||||||
|
|||||||
@@ -287,9 +287,9 @@ Nginx (dev): gzip abilitato, `client_max_body_size 50M`, `proxy_read_timeout 120
|
|||||||
|
|
||||||
## Visione (V3.0.0)
|
## Visione (V3.0.0)
|
||||||
|
|
||||||
Il runner di visione (`src/vision/`) e il suo worker FastAPI (`src/vision_worker/`) dipendono da `vendor/visionsuite`, un **submodule** su `ssh://git.tielogic.xyz:222`: 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. Pin attuale: bc0df16.
|
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: 9eb6f4c.
|
||||||
|
|
||||||
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.
|
||||||
|
|||||||
@@ -47,6 +47,13 @@ WORKDIR /app/flask_app
|
|||||||
RUN uv run --project /app pybabel compile -d translations && \
|
RUN uv run --project /app pybabel compile -d translations && \
|
||||||
chown -R app:app /app /tmp/uv-cache
|
chown -R app:app /app /tmp/uv-cache
|
||||||
|
|
||||||
|
# Commit mostrato nel piede di pagina (#70). .git e' escluso dal contesto di
|
||||||
|
# build (.dockerignore), quindi lo passa chi costruisce; facoltativo, senza il
|
||||||
|
# piede mostra solo la versione. Uso:
|
||||||
|
# APP_COMMIT=$(git rev-parse --short HEAD) docker compose build client
|
||||||
|
ARG APP_COMMIT=""
|
||||||
|
ENV APP_COMMIT=${APP_COMMIT}
|
||||||
|
|
||||||
USER app
|
USER app
|
||||||
|
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
|
|||||||
@@ -333,6 +333,7 @@ TieMeasureFlow/
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `docker compose -f docker-compose.dev.yml up -d --build` | Avvia servizi in sviluppo (build incluso) |
|
| `docker compose -f docker-compose.dev.yml up -d --build` | Avvia servizi in sviluppo (build incluso) |
|
||||||
| `docker compose -f docker-compose.dev.yml down` | Ferma e rimuove i container |
|
| `docker compose -f docker-compose.dev.yml down` | Ferma e rimuove i container |
|
||||||
|
| `APP_COMMIT=$(git rev-parse --short HEAD) docker compose build client` | Ricostruisci il client mostrando nel piè di pagina il commit (oltre alla versione di `pyproject.toml`); senza `APP_COMMIT` compare solo la versione |
|
||||||
| `docker compose logs -f server` | Segui log server in tempo reale |
|
| `docker compose logs -f server` | Segui log server in tempo reale |
|
||||||
| `docker compose logs -f client` | Segui log client in tempo reale |
|
| `docker compose logs -f client` | Segui log client in tempo reale |
|
||||||
| `docker compose ps` | Stato di tutti i servizi |
|
| `docker compose ps` | Stato di tutti i servizi |
|
||||||
@@ -515,6 +516,70 @@ Copia `.env.example` in `.env` e configura:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Camera di stazione (demo fiera, #72)
|
||||||
|
|
||||||
|
Per acquisire il fotogramma da una camera locale (per esempio quella del TC bench) senza passare dall'upload, sul PC della stazione gira un piccolo servizio nativo, fuori da Docker, perché la camera GigE non si raggiunge dal container. Si avvia dalla radice del repository con l'ambiente di VisionSuite (`uv sync --all-packages` in `vendor/visionsuite`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/visionsuite/.venv/bin/python scripts/camera_service.py [--host 127.0.0.1] [--port 8765] [--folder <cartella immagini>]
|
||||||
|
```
|
||||||
|
|
||||||
|
Con `--folder` la sorgente `camera:folder` restituisce a rotazione le immagini della cartella, utile per provare senza hardware. Su Linux, perché il container `client` lo raggiunga, si usa `--host 172.17.0.1` (gateway di docker0; il compose di sviluppo mappa `host.docker.internal` su quell'host). Su Windows con Docker Desktop si presume che `host.docker.internal` raggiunga il loopback dell'host, ma va verificato sul PC della demo.
|
||||||
|
|
||||||
|
⚠️ Il servizio non ha autenticazione: un `--host` diverso dal loopback va usato solo su una rete isolata.
|
||||||
|
|
||||||
|
Nella schermata Stazioni si compilano `camera_url` (per esempio `http://host.docker.internal:8765`) e `camera_source` (`camera:<seriale>` oppure `camera:folder`). Per avere due postazioni sullo stesso PC si abilita `STATION_SWITCH_ENABLED`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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).
|
||||||
|
|||||||
@@ -75,6 +75,9 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.frontend
|
dockerfile: Dockerfile.frontend
|
||||||
|
args:
|
||||||
|
# Commit nel piede di pagina (#70); facoltativo, vuoto = solo versione
|
||||||
|
APP_COMMIT: ${APP_COMMIT:-}
|
||||||
container_name: tmflow-client
|
container_name: tmflow-client
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
@@ -85,6 +88,9 @@ services:
|
|||||||
# on-premise su LAN in HTTP semplice ha bisogno del cookie non-secure,
|
# on-premise su LAN in HTTP semplice ha bisogno del cookie non-secure,
|
||||||
# altrimenti il browser non lo rimanda mai indietro.
|
# altrimenti il browser non lo rimanda mai indietro.
|
||||||
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false}
|
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false}
|
||||||
|
# #72: da container si raggiunge il servizio camera (vs-gui) sull'host Linux
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
depends_on:
|
depends_on:
|
||||||
server:
|
server:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -89,6 +89,9 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.frontend
|
dockerfile: Dockerfile.frontend
|
||||||
|
args:
|
||||||
|
# Commit nel piede di pagina (#70); facoltativo, vuoto = solo versione
|
||||||
|
APP_COMMIT: ${APP_COMMIT:-}
|
||||||
container_name: tmflow-client
|
container_name: tmflow-client
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
|
|||||||
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,601 @@
|
|||||||
|
"""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 camere_da_scaldare(db: Path) -> list[tuple[str, str]]:
|
||||||
|
"""Coppie (camera_url, camera_source) configurate nel db (stazioni e task di ricetta), senza doppioni.
|
||||||
|
Solo sorgenti "camera:" che aprono hardware (non "camera:folder"). Errore sqlite -> lista vuota (#108)."""
|
||||||
|
coppie: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
def aggiungi(url, src) -> None:
|
||||||
|
# #108: valida la coppia e scarta duplicati e sorgenti che non aprono hardware
|
||||||
|
if not (isinstance(url, str) and isinstance(src, str) and url.strip() and src.strip()):
|
||||||
|
return
|
||||||
|
if not src.startswith("camera:") or src == "camera:folder":
|
||||||
|
return
|
||||||
|
if (url, src) not in coppie:
|
||||||
|
coppie.append((url, src))
|
||||||
|
|
||||||
|
try:
|
||||||
|
con = sqlite3.connect(f"file:{db.as_posix()}?mode=ro", uri=True, timeout=2)
|
||||||
|
try:
|
||||||
|
for url, src in con.execute("SELECT camera_url, camera_source FROM stations"):
|
||||||
|
aggiungi(url, src)
|
||||||
|
for (blob,) in con.execute("SELECT acquisition_json FROM recipe_tasks WHERE acquisition_json IS NOT NULL"):
|
||||||
|
try:
|
||||||
|
cfg = json.loads(blob)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue # #108: JSON non valido, si salta
|
||||||
|
if isinstance(cfg, dict):
|
||||||
|
aggiungi(cfg.get("camera_url"), cfg.get("camera_source"))
|
||||||
|
finally:
|
||||||
|
con.close()
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
return coppie
|
||||||
|
|
||||||
|
|
||||||
|
def scalda_camere(db: Path) -> None:
|
||||||
|
"""Apre ogni camera configurata con un grab a vuoto, cosi' il primo "Acquisisci" non aspetta ~25 s (#108).
|
||||||
|
Gira in un thread daemon: nessuna eccezione esce."""
|
||||||
|
try:
|
||||||
|
camere = camere_da_scaldare(db)
|
||||||
|
if not camere:
|
||||||
|
return
|
||||||
|
for _ in range(300): # aspetta il servizio camera (max ~60 s), come _apri_browser_quando_pronto
|
||||||
|
if _porta_aperta(PORTA_CAMERA):
|
||||||
|
break
|
||||||
|
time.sleep(0.2)
|
||||||
|
else:
|
||||||
|
log.warning("scalda camere: servizio camera non raggiungibile, salto")
|
||||||
|
return
|
||||||
|
for url, src in camere:
|
||||||
|
t0 = time.monotonic()
|
||||||
|
try:
|
||||||
|
richiesta = urllib.request.Request(
|
||||||
|
f"{url.rstrip('/')}/camera/grab",
|
||||||
|
data=json.dumps({"source": src, "trigger": "continuo", "timeout_ms": 5000}).encode("utf-8"),
|
||||||
|
headers={"Content-Type": "application/json"}, method="POST")
|
||||||
|
with urllib.request.urlopen(richiesta, timeout=90) as r:
|
||||||
|
esito = f"HTTP {r.status}"
|
||||||
|
log.info("scalda camera %s: %s in %.1f s", src, esito, time.monotonic() - t0)
|
||||||
|
except Exception as e: # #108: una camera che non risponde non ferma le altre
|
||||||
|
log.warning("scalda camera %s: %s dopo %.1f s", src, e, time.monotonic() - t0)
|
||||||
|
except Exception:
|
||||||
|
log.exception("scalda camere non riuscito")
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
# #108: apre le camere configurate con un grab a vuoto, con o senza browser
|
||||||
|
threading.Thread(target=scalda_camere, args=(dati / "tmf.db",), daemon=True).start()
|
||||||
|
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,72 @@
|
|||||||
|
<#
|
||||||
|
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). Scrive vision-engine-version.txt nella radice (sha del submodule
|
||||||
|
vendor/visionsuite) se git c'e'; senza git e senza il file si ferma con un errore chiaro (#109).
|
||||||
|
|
||||||
|
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), git (opzionale: senza, serve vision-engine-version.txt gia' nella radice), 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
|
||||||
|
|
||||||
|
# #109: il worker di visione rifiuta ogni job senza VISION_ENGINE_VERSION, che avvio.py legge da
|
||||||
|
# <radice>\vision-engine-version.txt (come nel pacchetto, build.ps1). Da sorgente lo scrive questo script.
|
||||||
|
$versione = Join-Path $radice 'vision-engine-version.txt'
|
||||||
|
$shaMotore = $null
|
||||||
|
# #109: solo se il submodule e' inizializzato (ha .git): altrimenti git risale al repo padre e darebbe lo sha di TMF
|
||||||
|
if ((Get-Command git -ErrorAction SilentlyContinue) -and (Test-Path (Join-Path $radice 'vendor\visionsuite\.git'))) {
|
||||||
|
$shaMotore = (git -C (Join-Path $radice 'vendor\visionsuite') rev-parse HEAD 2>$null)
|
||||||
|
if ($LASTEXITCODE -ne 0) { $shaMotore = $null }
|
||||||
|
}
|
||||||
|
if ($shaMotore) {
|
||||||
|
# 40 caratteri ASCII, UTF8 senza BOM e senza a capo (avvio.py fa .strip(), che non toglie il BOM)
|
||||||
|
[System.IO.File]::WriteAllText($versione, $shaMotore.Trim(), (New-Object System.Text.UTF8Encoding $false))
|
||||||
|
} elseif (Test-Path $versione) {
|
||||||
|
Write-Host "git non disponibile: uso $versione = $((Get-Content -Raw $versione).Trim())"
|
||||||
|
} else {
|
||||||
|
throw "Manca vision-engine-version.txt e git non puo' leggere vendor\visionsuite. Rimedio: crea l'archivio con 'uv run python scripts/archivio_sorgente.py --out tmf.zip' da un clone con git, oppure scrivi in $versione lo sha del submodule (40 caratteri, ASCII, senza BOM e senza a capo)."
|
||||||
|
}
|
||||||
|
|
||||||
|
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,357 @@
|
|||||||
|
"""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_versione_motore_visione_letta_dalla_radice_sorgente(avvio, tmp_path, monkeypatch):
|
||||||
|
# #109: da sorgente il file vision-engine-version.txt sta nella radice del repo e arriva al worker
|
||||||
|
prog = tmp_path / "prog"
|
||||||
|
prog.mkdir()
|
||||||
|
(prog / "vision-engine-version.txt").write_bytes(b"a" * 40)
|
||||||
|
monkeypatch.setattr(avvio, "cartella_programma", lambda: prog)
|
||||||
|
avvio.imposta_ambiente(tmp_path / "dati", "127.0.0.1", {k: "x" * 32 for k in avvio.SEGRETI})
|
||||||
|
assert os.environ["VISION_ENGINE_VERSION"] == "a" * 40
|
||||||
|
|
||||||
|
|
||||||
|
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)]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- scalda camere (#108)
|
||||||
|
|
||||||
|
def _db_camere(percorso):
|
||||||
|
# #108: db minimo con le due tabelle lette da camere_da_scaldare
|
||||||
|
con = sqlite3.connect(percorso)
|
||||||
|
con.execute("CREATE TABLE stations (id INTEGER PRIMARY KEY, camera_url TEXT, camera_source TEXT)")
|
||||||
|
con.execute("CREATE TABLE recipe_tasks (id INTEGER PRIMARY KEY, acquisition_json TEXT)")
|
||||||
|
con.executemany("INSERT INTO stations (camera_url, camera_source) VALUES (?, ?)", [
|
||||||
|
("http://127.0.0.1:8765", "camera:601020"), # valida
|
||||||
|
(None, None), # stazione senza camera
|
||||||
|
("http://127.0.0.1:8765", "camera:folder"), # non apre hardware
|
||||||
|
])
|
||||||
|
con.executemany("INSERT INTO recipe_tasks (acquisition_json) VALUES (?)", [
|
||||||
|
(json.dumps({"camera_url": "http://127.0.0.1:8765", "camera_source": "camera:601020"}),), # duplicato
|
||||||
|
(json.dumps({"camera_url": "http://10.0.0.5:8765/", "camera_source": "camera:777"}),), # valida
|
||||||
|
("{rotto",), # JSON non valido
|
||||||
|
(None,),
|
||||||
|
])
|
||||||
|
con.commit()
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_camere_da_scaldare_filtra_e_deduplica(avvio, tmp_path):
|
||||||
|
db = tmp_path / "tmf.db"
|
||||||
|
_db_camere(db)
|
||||||
|
assert avvio.camere_da_scaldare(db) == [
|
||||||
|
("http://127.0.0.1:8765", "camera:601020"),
|
||||||
|
("http://10.0.0.5:8765/", "camera:777"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_camere_da_scaldare_db_inesistente(avvio, tmp_path):
|
||||||
|
assert avvio.camere_da_scaldare(tmp_path / "non_esiste.db") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_scalda_camere_una_chiamata_per_coppia(avvio, tmp_path, monkeypatch):
|
||||||
|
db = tmp_path / "tmf.db"
|
||||||
|
_db_camere(db)
|
||||||
|
monkeypatch.setattr(avvio, "_porta_aperta", lambda porta: True)
|
||||||
|
chiamate = []
|
||||||
|
|
||||||
|
class Risposta:
|
||||||
|
status = 200
|
||||||
|
def __enter__(self): return self
|
||||||
|
def __exit__(self, *a): return False
|
||||||
|
|
||||||
|
def finto_urlopen(richiesta, timeout=None):
|
||||||
|
chiamate.append((richiesta.full_url, json.loads(richiesta.data), richiesta.get_method(), timeout))
|
||||||
|
return Risposta()
|
||||||
|
|
||||||
|
monkeypatch.setattr(avvio.urllib.request, "urlopen", finto_urlopen)
|
||||||
|
avvio.scalda_camere(db)
|
||||||
|
corpo = {"trigger": "continuo", "timeout_ms": 5000}
|
||||||
|
assert chiamate == [
|
||||||
|
("http://127.0.0.1:8765/camera/grab", {"source": "camera:601020", **corpo}, "POST", 90),
|
||||||
|
("http://10.0.0.5:8765/camera/grab", {"source": "camera:777", **corpo}, "POST", 90),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_scalda_camere_eccezione_urlopen_non_esce(avvio, tmp_path, monkeypatch):
|
||||||
|
db = tmp_path / "tmf.db"
|
||||||
|
_db_camere(db)
|
||||||
|
monkeypatch.setattr(avvio, "_porta_aperta", lambda porta: True)
|
||||||
|
tentativi = []
|
||||||
|
|
||||||
|
def urlopen_rotto(richiesta, timeout=None):
|
||||||
|
tentativi.append(richiesta.full_url)
|
||||||
|
raise OSError("camera irraggiungibile")
|
||||||
|
|
||||||
|
monkeypatch.setattr(avvio.urllib.request, "urlopen", urlopen_rotto)
|
||||||
|
avvio.scalda_camere(db) # non solleva e passa alla camera successiva
|
||||||
|
assert len(tentativi) == 2
|
||||||
@@ -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,108 @@
|
|||||||
|
"""Archivio sorgente di TieMeasureFlow per un PC senza git (TieMeasureFlow#109).
|
||||||
|
|
||||||
|
Cosa fa
|
||||||
|
Crea uno zip del repo a un commit, con dentro anche il submodule vendor/visionsuite (che
|
||||||
|
`git archive` da solo NON include) e il file vision-engine-version.txt nella radice dello zip.
|
||||||
|
Quel file contiene lo sha del submodule REGISTRATO nel commit (non la HEAD del checkout del
|
||||||
|
submodule): packaging/windows/avvio.py lo legge e lo passa al worker di visione come
|
||||||
|
VISION_ENGINE_VERSION; senza, il worker rifiuta ogni job. Formato: 40 caratteri ASCII,
|
||||||
|
senza BOM e senza a capo (avvio.py usa .strip(), che non toglie un BOM).
|
||||||
|
|
||||||
|
Come si usa (dalla radice del repo, con il submodule inizializzato)
|
||||||
|
uv run python scripts/archivio_sorgente.py --out C:/tmp/tmf.zip [--ref HEAD]
|
||||||
|
Esce 0 e stampa lo sha; esce 1 con un messaggio e senza lasciare lo zip se il submodule
|
||||||
|
non e' inizializzato o non ha il commit registrato (git submodule update --init vendor/visionsuite).
|
||||||
|
|
||||||
|
Dipende da: git nel PATH, solo libreria standard.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SUBMODULE = "vendor/visionsuite"
|
||||||
|
FILE_VERSIONE = "vision-engine-version.txt"
|
||||||
|
|
||||||
|
|
||||||
|
class ErroreArchivio(RuntimeError):
|
||||||
|
"""Errore chiaro da mostrare all'operatore (niente traceback)."""
|
||||||
|
|
||||||
|
|
||||||
|
def _git(cartella: Path, *args: str, binario: bool = False):
|
||||||
|
"""Esegue git in `cartella`; solleva ErroreArchivio con lo stderr se fallisce."""
|
||||||
|
r = subprocess.run(["git", "-C", str(cartella), *args], capture_output=True)
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise ErroreArchivio(f"git {' '.join(args)} fallito in {cartella}: {r.stderr.decode(errors='replace').strip()}")
|
||||||
|
return r.stdout if binario else r.stdout.decode().strip()
|
||||||
|
|
||||||
|
|
||||||
|
def sha_registrato(repo: Path, ref: str) -> str:
|
||||||
|
"""Sha del submodule registrato nel commit `ref` (gitlink, modo 160000), non la HEAD del checkout."""
|
||||||
|
campi = _git(repo, "ls-tree", ref, SUBMODULE).split()
|
||||||
|
if len(campi) < 3 or campi[0] != "160000":
|
||||||
|
raise ErroreArchivio(f"{SUBMODULE} non e' un submodule registrato in {ref}")
|
||||||
|
return campi[2]
|
||||||
|
|
||||||
|
|
||||||
|
def crea_archivio(repo: Path, out: Path, ref: str = "HEAD") -> str:
|
||||||
|
"""Scrive lo zip in `out` e ritorna lo sha del submodule; in caso di errore non lascia nulla."""
|
||||||
|
repo, out = Path(repo), Path(out)
|
||||||
|
sha = sha_registrato(repo, ref)
|
||||||
|
sub = repo / SUBMODULE
|
||||||
|
# submodule non inizializzato o senza il commit registrato: errore prima di scrivere lo zip
|
||||||
|
if not (sub / ".git").exists():
|
||||||
|
raise ErroreArchivio(f"{SUBMODULE} non e' inizializzato: git submodule update --init {SUBMODULE}")
|
||||||
|
if subprocess.run(["git", "-C", str(sub), "cat-file", "-e", f"{sha}^{{commit}}"], capture_output=True).returncode != 0:
|
||||||
|
raise ErroreArchivio(f"{SUBMODULE} non ha il commit {sha}: git submodule update --init {SUBMODULE}")
|
||||||
|
|
||||||
|
zip_repo = _git(repo, "archive", "--format=zip", ref, binario=True)
|
||||||
|
zip_sub = _git(sub, "archive", "--format=zip", f"--prefix={SUBMODULE}/", sha, binario=True)
|
||||||
|
|
||||||
|
out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd, tmp = tempfile.mkstemp(suffix=".zip.tmp", dir=out.parent) # scrittura atomica: o c'e' tutto o niente
|
||||||
|
os.close(fd)
|
||||||
|
try:
|
||||||
|
viste: set[str] = set()
|
||||||
|
with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as z:
|
||||||
|
for blob in (zip_repo, zip_sub):
|
||||||
|
with zipfile.ZipFile(io.BytesIO(blob)) as sorgente:
|
||||||
|
for info in sorgente.infolist():
|
||||||
|
if info.filename in viste:
|
||||||
|
continue # es. la cartella vendor/visionsuite/ del gitlink, gia' nel primo zip
|
||||||
|
viste.add(info.filename)
|
||||||
|
z.writestr(info, sorgente.read(info.filename), compress_type=zipfile.ZIP_DEFLATED)
|
||||||
|
if FILE_VERSIONE in viste:
|
||||||
|
raise ErroreArchivio(f"{FILE_VERSIONE} e' gia' nel repo a {ref}: va tolto dal versionamento")
|
||||||
|
# 40 caratteri ASCII, senza BOM e senza a capo (avvio.py fa .strip(), che non toglie un BOM)
|
||||||
|
z.writestr(FILE_VERSIONE, sha.encode("ascii"))
|
||||||
|
os.replace(tmp, out)
|
||||||
|
except BaseException:
|
||||||
|
if os.path.exists(tmp):
|
||||||
|
os.unlink(tmp)
|
||||||
|
raise
|
||||||
|
return sha
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
p = argparse.ArgumentParser(description="Zip del sorgente con submodule e vision-engine-version.txt (#109)")
|
||||||
|
p.add_argument("--out", required=True, type=Path, help="file .zip da creare")
|
||||||
|
p.add_argument("--ref", default="HEAD", help="commit da archiviare (default HEAD)")
|
||||||
|
a = p.parse_args(argv)
|
||||||
|
radice = Path(__file__).resolve().parents[1]
|
||||||
|
try:
|
||||||
|
sha = crea_archivio(radice, a.out, a.ref)
|
||||||
|
except ErroreArchivio as e:
|
||||||
|
print(f"ERRORE: {e}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print(f"{a.out} creato; {FILE_VERSIONE} = {sha}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -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
@@ -0,0 +1,817 @@
|
|||||||
|
"""Servizio camera minimo di stazione (TieMeasureFlow, #72).
|
||||||
|
|
||||||
|
Cosa fa: espone via HTTP l'acquisizione di una camera (vs-camera: GigE/GenTL,
|
||||||
|
OpenCV, cartella di immagini) con lo stesso contratto di `vs-gui`, ma solo
|
||||||
|
`grab` (copiato da TieVision-Studio, src/visionsuite/gui/server.py):
|
||||||
|
GET /camera/sources -> {"sources": [{name, kind, label}]}
|
||||||
|
POST /camera/grab json {source, trigger="continuo", timeout_ms=5000,
|
||||||
|
grezzo=false} -> PNG
|
||||||
|
#84: se la sorgente ha una mappa di distorsione salvata il frame esce
|
||||||
|
raddrizzato (header X-Raddrizzato: si|no); "grezzo": true lo dà com'è.
|
||||||
|
Errori: 400 parametri, 404 sorgente inesistente, 503 guasto camera, 504 timeout.
|
||||||
|
|
||||||
|
Calibrazione a scacchiera (#83, logica in scripts/calibrazione.py).
|
||||||
|
#91: archivio UNICO di calibrazioni con nome; una sorgente ne ha una attiva:
|
||||||
|
POST /camera/calibrate multipart: source (obbligatorio solo senza image),
|
||||||
|
square_mm, image (file, opzionale: senza, grab GREZZO dalla sorgente),
|
||||||
|
cols/rows (opzionali). NON salva mai (il vecchio campo save e' ignorato).
|
||||||
|
#84: SENZA cols/rows = calibrazione densa (default): una foto di
|
||||||
|
scacchiera che riempie il campo, griglia trovata da sola, mappa di
|
||||||
|
distorsione; mm/px e px/mm sono quelli dell'immagine RADDRIZZATA. CON
|
||||||
|
cols/rows (angoli interni) = metodo planare di prima. Risposta: i campi
|
||||||
|
del calcolo + overlay_png (b64), calibrated_at, origine ("camera:<src>" |
|
||||||
|
"file:<nome>"), calcolo_id (il servizio tiene in memoria gli ultimi 8
|
||||||
|
calcoli) e metodo ("densa"|"planare"), distorsione (bool) e, per la
|
||||||
|
densa, residuo_rms_px, residuo_max_px, copertura_pct;
|
||||||
|
422 scacchiera non trovata o parametri invalidi
|
||||||
|
GET /camera/calibrazioni[?source=S] -> {calibrazioni: [{nome, origine,
|
||||||
|
image_width, image_height, px_per_mm, mm_per_px, metodo, square_mm,
|
||||||
|
calibrated_at, attiva_su}], attiva: nome|null} (dalla piu' recente)
|
||||||
|
POST /camera/calibrazioni json {calcolo_id, nome, sovrascrivi=false} -> 201
|
||||||
|
metadati. 404 calcolo scaduto, 400 nome non valido, 409 nome gia' usato
|
||||||
|
POST /camera/calibrazioni/manuale json {source, nome, mm_per_px, sovrascrivi=false}
|
||||||
|
-> 201 metadati (#95: mm/px impostato a mano, metodo "manuale", nessuna
|
||||||
|
mappa; non la attiva). 400 mm/px fuori 0.00001..100 o nome non valido,
|
||||||
|
409 nome gia' usato; la risoluzione e' quella del frame tenuto o di un grab
|
||||||
|
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)
|
||||||
|
GET /visore/{image_viewer.js|save_image.js|vision_overlay.js} -> i JS del <tmf-viewer> del frontend (#122), altri nomi 404
|
||||||
|
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.
|
||||||
|
#110: specchiatura per sorgente, in software (cv2.flip) su OGNI frame letto dalla
|
||||||
|
sorgente, prima di raddrizzatura/calibrazione (anteprima, grab, calibrazione da camera,
|
||||||
|
copia bloccata); non tocca l'immagine caricata a mano in /camera/calibrate.
|
||||||
|
GET /camera/orientamento?source=S -> {source, orizzontale, verticale} (default false/false);
|
||||||
|
400 se manca source
|
||||||
|
PUT /camera/orientamento json {source, orizzontale: bool, verticale: bool} -> come GET +
|
||||||
|
"avviso" (stringa o null: la calibrazione attiva ha una mappa fatta con l'orientamento
|
||||||
|
di prima e va rifatta). 400 se source manca o i due campi non sono bool. Salva subito
|
||||||
|
su disco; scarta i frame tenuti (anteprima/blocca).
|
||||||
|
Le calibrazioni stanno in <config-dir>/calibrazioni, le attive in attive.json, l'orientamento
|
||||||
|
per sorgente in orientamento.json (#110) (default
|
||||||
|
%ProgramData%/Tielogic/TieMeasureFlow/acquisitore su Windows, ./acquisitore altrove).
|
||||||
|
|
||||||
|
Serve perche' vs-gui ascolta solo su 127.0.0.1 e il container `client` non lo
|
||||||
|
raggiunge; questo servizio permette di scegliere l'host di ascolto.
|
||||||
|
|
||||||
|
Uso (dalla radice del repo):
|
||||||
|
vendor/visionsuite/.venv/bin/python scripts/camera_service.py \
|
||||||
|
[--host 127.0.0.1] [--port 8765] [--folder <cartella immagini>] \
|
||||||
|
[--config-dir <cartella calibrazioni>]
|
||||||
|
--folder abilita la sorgente finta `camera:folder` (loop sulle immagini).
|
||||||
|
Immagini caricate (#92): POST/DELETE /camera/immagini, in <config-dir>/immagini; sono sorgenti
|
||||||
|
a pieno titolo (/camera/sources, grab, anteprima, calibrate). POST /camera/anteprima/calibrata
|
||||||
|
raddrizza l'ultimo frame grezzo dell'anteprima con la calibrazione attiva, senza leggerne uno nuovo.
|
||||||
|
Da Docker su Linux: --host 172.17.0.1 (gateway di docker0).
|
||||||
|
|
||||||
|
Dipende da: venv di VisionSuite (`uv sync --all-packages` in vendor/visionsuite):
|
||||||
|
vs-camera, fastapi, uvicorn, opencv, python-multipart (rotta /camera/calibrate),
|
||||||
|
visionsuite.calibration (vs-core).
|
||||||
|
|
||||||
|
AVVERTENZA: nessuna autenticazione. Un --host diverso dal loopback espone la
|
||||||
|
camera a chiunque raggiunga la porta: solo su rete isolata.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json # #110: orientamento.json
|
||||||
|
import logging
|
||||||
|
import math # #95: mm/px finito nella calibrazione manuale
|
||||||
|
import os
|
||||||
|
import re # #92: nome sicuro per le immagini caricate
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time # #92: scadenza della cache delle camere
|
||||||
|
import urllib.parse # #92: nome calibrazione nell'header X-Calibrazione
|
||||||
|
import uuid # #91 id dei calcoli in attesa di nome
|
||||||
|
from collections import OrderedDict
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
from fastapi import Body, FastAPI, File, Form, HTTPException, UploadFile
|
||||||
|
from fastapi.concurrency import run_in_threadpool # #83: il grab bloccante fuori dall'event loop
|
||||||
|
from fastapi.responses import HTMLResponse, Response
|
||||||
|
|
||||||
|
from visionsuite.camera.camera_interface import CameraError, CameraGrabError, CameraStateError
|
||||||
|
from visionsuite.camera.sources import _ESTENSIONI, SourceNotFound, list_sources, open_source # #92: _ESTENSIONI = tipi ammessi per le immagini caricate
|
||||||
|
|
||||||
|
# #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
|
||||||
|
|
||||||
|
# #122: i tre file JS del frontend che la pagina carica su /visore/<nome> (solo questi nomi: nessun percorso arbitrario dall'URL).
|
||||||
|
# Cartelle cercate nell'ordine: pacchetto Nuitka (static/ accanto al modulo, copiata da build.ps1) e sorgenti (src/frontend/flask_app/static/js)
|
||||||
|
_FILE_VISORE = ("image_viewer.js", "save_image.js", "vision_overlay.js")
|
||||||
|
_QUI = Path(__file__).resolve().parent
|
||||||
|
_CARTELLE_VISORE = [_QUI / "static" / "js", _QUI.parent / "src" / "frontend" / "flask_app" / "static" / "js"]
|
||||||
|
|
||||||
|
_TRIGGER = ("continuo",)
|
||||||
|
_MAX_CALCOLI = 8 # #91: calcoli tenuti in memoria in attesa di un nome
|
||||||
|
_CACHE_CAMERE_S = 60.0 # #92: durata della cache della ricerca camere
|
||||||
|
_log = logging.getLogger("camera_service") # #87: errori non bloccanti (settaggi)
|
||||||
|
# #87: i quattro parametri regolabili, nell'ordine di presentazione
|
||||||
|
_PARAMETRI = ("esposizione", "esposizione_auto", "guadagno", "guadagno_auto")
|
||||||
|
# #87: in scrittura prima gli automatismi (un valore manuale con auto acceso viene riscritto)
|
||||||
|
_ORDINE_SCRITTURA = ("esposizione_auto", "guadagno_auto", "esposizione", "guadagno")
|
||||||
|
|
||||||
|
# errori noti di vs-camera (inglese) -> italiano; esatti, poi per prefisso
|
||||||
|
_MESSAGGI = {
|
||||||
|
"Camera is not open": "La camera non è aperta",
|
||||||
|
"Camera is not grabbing": "La camera non sta acquisendo",
|
||||||
|
"Grab cancelled (grabbing stopped)": "Acquisizione annullata (acquisizione fermata)",
|
||||||
|
"Failed to read frame from RTSP stream": "Lettura del fotogramma dallo stream RTSP non riuscita",
|
||||||
|
}
|
||||||
|
_PREFISSI = (
|
||||||
|
("Failed to open OpenCV device", "Apertura del dispositivo OpenCV non riuscita"),
|
||||||
|
("Failed to open RTSP stream", "Apertura dello stream RTSP non riuscita"),
|
||||||
|
("Failed to reconnect to RTSP stream", "Riconnessione allo stream RTSP non riuscita"),
|
||||||
|
("Failed to read frame after", "Lettura del fotogramma non riuscita dopo"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _traduci(messaggio: str) -> str:
|
||||||
|
if messaggio in _MESSAGGI:
|
||||||
|
return _MESSAGGI[messaggio]
|
||||||
|
for inglese, italiano in _PREFISSI:
|
||||||
|
if messaggio.startswith(inglese):
|
||||||
|
return italiano + messaggio[len(inglese):]
|
||||||
|
return messaggio
|
||||||
|
|
||||||
|
|
||||||
|
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 = {}
|
||||||
|
grezzi: dict = {} # #92: sorgente -> ultimo frame pieno GREZZO dell'anteprima (per anteprima/calibrata)
|
||||||
|
bloccati: dict = {} # #94: sorgente -> copia del frame grezzo pieno «bloccata» per il calcolo (stesso lock di grezzi)
|
||||||
|
cartella_immagini = archivio.cartella / "immagini" # #92: immagini caricate dalla pagina (creata al bisogno)
|
||||||
|
lock = threading.Lock() # protegge SOLO i dizionari, mai un'apertura
|
||||||
|
lock_sorgenti: dict = {} # nome sorgente -> Lock che serializza apertura e grab
|
||||||
|
calcoli: OrderedDict = OrderedDict() # #91: calcolo_id -> (dati, mappa); gli ultimi _MAX_CALCOLI
|
||||||
|
lock_calcoli = threading.Lock()
|
||||||
|
file_orientamento = archivio.cartella / "orientamento.json" # #110: {sorgente: {orizzontale, verticale}}
|
||||||
|
|
||||||
|
def _carica_orientamenti() -> dict:
|
||||||
|
"""#110: orientamenti salvati; file assente o rotto -> {} (mai eccezione)."""
|
||||||
|
if not file_orientamento.is_file():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
dati = json.loads(file_orientamento.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(dati, dict):
|
||||||
|
raise ValueError("non e' un oggetto JSON")
|
||||||
|
return {n: {"orizzontale": v.get("orizzontale") is True, "verticale": v.get("verticale") is True}
|
||||||
|
for n, v in dati.items() if isinstance(n, str) and isinstance(v, dict)}
|
||||||
|
except (OSError, ValueError) as e:
|
||||||
|
_log.warning("orientamento non caricato (%s): %s", file_orientamento, e)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
orientamenti: dict = _carica_orientamenti() # #110: sorgente -> {"orizzontale": bool, "verticale": bool}; protetto da `lock`
|
||||||
|
|
||||||
|
def _specchia(nome: str, frame):
|
||||||
|
"""#110: applica l'orientamento della sorgente al frame letto; nessuno -> lo stesso oggetto, senza copia."""
|
||||||
|
with lock:
|
||||||
|
o = orientamenti.get(nome) or {}
|
||||||
|
orizz, vert = o.get("orizzontale", False), o.get("verticale", False)
|
||||||
|
if orizz and vert:
|
||||||
|
return cv2.flip(frame, -1) # entrambi = rotazione di 180 gradi
|
||||||
|
if orizz:
|
||||||
|
return cv2.flip(frame, 1)
|
||||||
|
if vert:
|
||||||
|
return cv2.flip(frame, 0)
|
||||||
|
return frame
|
||||||
|
|
||||||
|
def _config() -> dict:
|
||||||
|
if folder is None:
|
||||||
|
return {}
|
||||||
|
return {"folder": {"folder": str(folder), "loop": True}}
|
||||||
|
|
||||||
|
def _lock_di(nome: str) -> threading.Lock:
|
||||||
|
with lock:
|
||||||
|
return lock_sorgenti.setdefault(nome, threading.Lock())
|
||||||
|
|
||||||
|
# #92: la ricerca camere di list_sources dura ~10 s: il risultato (gia' filtrato) resta 60 s in cache
|
||||||
|
cache_camere: dict = {"voci": None, "t": 0.0}
|
||||||
|
lock_ricerca = threading.Lock() # due richieste insieme non lanciano due ricerche
|
||||||
|
|
||||||
|
def _camere(aggiorna: bool = False) -> list:
|
||||||
|
with lock_ricerca:
|
||||||
|
fresca = cache_camere["voci"] is not None and time.monotonic() - cache_camere["t"] < _CACHE_CAMERE_S
|
||||||
|
if fresca and not aggiorna:
|
||||||
|
return cache_camere["voci"]
|
||||||
|
voci = list_sources(None, cameras=True, config=_config())
|
||||||
|
# camera:file (replay) non e' mai configurato in TMF -> fuori dall'elenco
|
||||||
|
cache_camere["voci"] = [{"name": v.name, "kind": v.kind, "label": v.label}
|
||||||
|
for v in voci if v.kind in ("camera", "virtual") and v.name != "camera:file"]
|
||||||
|
cache_camere["t"] = time.monotonic()
|
||||||
|
return cache_camere["voci"]
|
||||||
|
|
||||||
|
def _prima_ricerca() -> None:
|
||||||
|
try:
|
||||||
|
_camere()
|
||||||
|
except Exception: # noqa: BLE001 - e' solo un riscaldamento: la prima richiesta riprova
|
||||||
|
_log.exception("prima ricerca camere fallita")
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _vita(_app):
|
||||||
|
# #92: la prima ricerca parte all'avvio, cosi' la prima pagina non aspetta
|
||||||
|
threading.Thread(target=_prima_ricerca, daemon=True, name="ricerca-camere").start()
|
||||||
|
yield
|
||||||
|
with lock: # alla chiusura rilascia le camere aperte
|
||||||
|
sorgenti = list(aperte.values())
|
||||||
|
aperte.clear()
|
||||||
|
for s in sorgenti:
|
||||||
|
try:
|
||||||
|
s.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
app = FastAPI(title="TMF camera service", lifespan=_vita)
|
||||||
|
|
||||||
|
@app.get("/camera/sources")
|
||||||
|
def camera_sources(aggiorna: bool = False): # #92: aggiorna=true forza la ricerca (camera appena collegata)
|
||||||
|
elenco = list(_camere(aggiorna))
|
||||||
|
# #92: in coda le immagini caricate, sempre fresche dalla cartella (costano nulla)
|
||||||
|
for v in list_sources(cartella_immagini, cameras=False):
|
||||||
|
elenco.append({"name": v.name, "kind": "image", "label": f"immagine — {v.name}"})
|
||||||
|
return {"sources": elenco}
|
||||||
|
|
||||||
|
def _chiudi(nome: str) -> None:
|
||||||
|
"""#92: chiude la sorgente aperta e scorda il frame tenuto (immagine sostituita o tolta)."""
|
||||||
|
with _lock_di(nome):
|
||||||
|
with lock:
|
||||||
|
s = aperte.pop(nome, None)
|
||||||
|
grezzi.pop(nome, None)
|
||||||
|
bloccati.pop(nome, None) # #94: tolta l'immagine/chiusa la sorgente, anche la copia bloccata
|
||||||
|
if s is not None:
|
||||||
|
try:
|
||||||
|
s.close()
|
||||||
|
except Exception: # noqa: BLE001 - la chiusura non deve far fallire la rotta
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _nome_immagine(grezzo: str) -> str:
|
||||||
|
"""#92: solo il nome del file, coi caratteri sicuri di Archivio._file; "" se non ammesso."""
|
||||||
|
base = Path((grezzo or "").replace("\\", "/")).name
|
||||||
|
pulito = re.sub(r"[^A-Za-z0-9._-]+", "_", base).strip("._")
|
||||||
|
radice, punto, estensione = pulito.rpartition(".")
|
||||||
|
if not punto or not radice or f".{estensione.lower()}" not in _ESTENSIONI:
|
||||||
|
return ""
|
||||||
|
return f"{radice[:100]}.{estensione}"
|
||||||
|
|
||||||
|
@app.post("/camera/immagini", status_code=201)
|
||||||
|
async def immagini_carica(image: UploadFile | None = File(None)):
|
||||||
|
if image is None:
|
||||||
|
raise HTTPException(400, "manca il file «image»")
|
||||||
|
nome = _nome_immagine(image.filename or "")
|
||||||
|
if not nome:
|
||||||
|
raise HTTPException(400, "estensione non ammessa: " + ", ".join(_ESTENSIONI))
|
||||||
|
contenuto = await image.read()
|
||||||
|
# #92: 422 se non e' un'immagine; la verifica sta PRIMA della scrittura, cosi' non resta nulla su disco
|
||||||
|
if not contenuto or cv2.imdecode(np.frombuffer(contenuto, np.uint8), cv2.IMREAD_UNCHANGED) is None:
|
||||||
|
raise HTTPException(422, "il file caricato non è leggibile come immagine")
|
||||||
|
cartella_immagini.mkdir(parents=True, exist_ok=True)
|
||||||
|
(cartella_immagini / nome).write_bytes(contenuto) # stesso nome = sostituisce
|
||||||
|
await run_in_threadpool(_chiudi, nome) # se era aperta, si riapre sul file nuovo
|
||||||
|
return {"source": nome, "label": f"immagine — {nome}", "kind": "image"}
|
||||||
|
|
||||||
|
@app.delete("/camera/immagini", status_code=204)
|
||||||
|
def immagini_elimina(nome: str = ""):
|
||||||
|
percorso = cartella_immagini / nome
|
||||||
|
# #92: solo il nome di un file dentro la cartella, niente percorsi
|
||||||
|
if not nome or Path(nome).name != nome or not percorso.is_file():
|
||||||
|
raise HTTPException(404, "immagine non trovata")
|
||||||
|
_chiudi(nome)
|
||||||
|
percorso.unlink()
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
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, cartella_immagini, config=_config()) # #92: un nome di file = immagine caricata
|
||||||
|
if hasattr(sorgente, "loop"): # #92: l'immagine singola non deve "finire" al secondo grab (anteprima continua)
|
||||||
|
sorgente.loop = True
|
||||||
|
# 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: _specchia(nome, s.grab(timeout))) # #87: corpo in _con_sorgente; #110: specchiato prima di tutto il resto
|
||||||
|
|
||||||
|
@app.post("/camera/grab")
|
||||||
|
def camera_grab(corpo: dict = Body(default=None)):
|
||||||
|
nome = corpo.get("source") if isinstance(corpo, dict) else None
|
||||||
|
if not isinstance(nome, str) or not nome.strip():
|
||||||
|
raise HTTPException(400, "manca «source»")
|
||||||
|
nome = nome.strip()
|
||||||
|
trigger = corpo.get("trigger", "continuo")
|
||||||
|
if trigger not in _TRIGGER:
|
||||||
|
raise HTTPException(400, "«trigger» supportato: solo continuo")
|
||||||
|
grezzo = corpo.get("grezzo", False) # #84: true = frame non raddrizzato
|
||||||
|
if not isinstance(grezzo, bool):
|
||||||
|
raise HTTPException(400, "«grezzo» deve essere true o false")
|
||||||
|
timeout = corpo.get("timeout_ms", 5000)
|
||||||
|
if isinstance(timeout, bool) or not isinstance(timeout, int) or not 1 <= timeout <= 10000:
|
||||||
|
raise HTTPException(400, "«timeout_ms» deve essere un intero fra 1 e 10000")
|
||||||
|
frame = _grab(nome, timeout) # #83: corpo estratto in _grab, invariato
|
||||||
|
raddrizzato = False
|
||||||
|
if not grezzo: # #84: sorgente calibrata (densa) -> frame raddrizzato dalla mappa in cache (#91: quella attiva)
|
||||||
|
mappa = archivio.leggi_mappa(nome)
|
||||||
|
if mappa is not None:
|
||||||
|
corretto = calibrazione.raddrizza(frame, mappa)
|
||||||
|
if corretto is not None: # None = dimensioni diverse da quelle calibrate
|
||||||
|
frame, raddrizzato = corretto, True
|
||||||
|
ok, buf = cv2.imencode(".png", frame)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(500, "codifica PNG fallita")
|
||||||
|
return Response(buf.tobytes(), media_type="image/png",
|
||||||
|
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)
|
||||||
|
bloccata: bool = Form(False), # #94: true = calcola sulla copia bloccata, senza grab
|
||||||
|
):
|
||||||
|
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")
|
||||||
|
elif bloccata: # #94: copia bloccata, nessun grab e nessun lock camera
|
||||||
|
nome = _sorgente(source)
|
||||||
|
with lock:
|
||||||
|
frame = bloccati.get(nome)
|
||||||
|
if frame is None:
|
||||||
|
raise HTTPException(409, "nessuna immagine bloccata su questa sorgente")
|
||||||
|
origine = nome
|
||||||
|
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"],
|
||||||
|
"bloccata": bool(bloccata and not contenuto)} # #94: solo nella risposta (non nel salvato): true se il frame e' la copia bloccata
|
||||||
|
|
||||||
|
# ---- #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.post("/camera/calibrazioni/manuale", status_code=201)
|
||||||
|
def calibrazioni_manuale(corpo: dict = Body(default=None)):
|
||||||
|
"""#95: calibrazione con mm/px impostato a mano (nessuna scacchiera, nessuna mappa); NON la attiva."""
|
||||||
|
corpo = corpo if isinstance(corpo, dict) else {}
|
||||||
|
sorgente = _sorgente(corpo.get("source"))
|
||||||
|
mm_px = corpo.get("mm_per_px")
|
||||||
|
if (isinstance(mm_px, bool) or not isinstance(mm_px, (int, float))
|
||||||
|
or not math.isfinite(mm_px) or not 0.00001 <= mm_px <= 100):
|
||||||
|
raise HTTPException(400, "mm/px deve essere un numero positivo")
|
||||||
|
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:
|
||||||
|
frame = grezzi.get(sorgente)
|
||||||
|
if frame is None: # nessun frame tenuto: un grab come /camera/grab (404/503/504)
|
||||||
|
frame = _grab(sorgente, 5000)
|
||||||
|
alt, larg = frame.shape[:2] # serve al controllo W×H di PUT /camera/calibrazione-attiva
|
||||||
|
dati = {"px_per_mm": 1.0 / mm_px, "mm_per_px": float(mm_px), "metodo": "manuale",
|
||||||
|
"image_width": larg, "image_height": alt, "origine": sorgente,
|
||||||
|
"calibrated_at": calibrazione.ora_iso(), "rms_px": None, "square_mm": None}
|
||||||
|
try:
|
||||||
|
return archivio.salva_nome(nome, dati, None, 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")
|
||||||
|
def leggi(s): # #92: il frame grezzo si tiene sotto il lock della sorgente
|
||||||
|
f = _specchia(nome, s.grab(timeout)) # #110: specchiato PRIMA di tenerlo, cosi' blocca/calibrata lo ricevono gia' specchiato
|
||||||
|
with lock:
|
||||||
|
grezzi[nome] = f.copy()
|
||||||
|
return f
|
||||||
|
frame = _con_sorgente(nome, leggi)
|
||||||
|
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.post("/camera/blocca")
|
||||||
|
def camera_blocca(corpo: dict = Body(default=None)):
|
||||||
|
"""#94: copia l'ultimo frame grezzo della sorgente; /camera/calibrate con bloccata=true calcola su quella."""
|
||||||
|
corpo = corpo if isinstance(corpo, dict) else {}
|
||||||
|
nome = _sorgente(corpo.get("source"))
|
||||||
|
with lock:
|
||||||
|
frame = grezzi.get(nome)
|
||||||
|
if frame is None:
|
||||||
|
raise HTTPException(404, "nessun frame: avviare l'anteprima o scattare")
|
||||||
|
bloccati[nome] = frame.copy()
|
||||||
|
alt, larg = frame.shape[:2]
|
||||||
|
return {"source": nome, "larghezza": larg, "altezza": alt}
|
||||||
|
|
||||||
|
@app.delete("/camera/blocca", status_code=204)
|
||||||
|
def camera_sblocca(source: str = ""):
|
||||||
|
with lock: # #94: idempotente, 204 anche se non c'era nulla
|
||||||
|
bloccati.pop(source.strip(), None)
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
@app.post("/camera/anteprima/calibrata")
|
||||||
|
def camera_anteprima_calibrata(corpo: dict = Body(default=None)):
|
||||||
|
"""#92: l'ultimo frame grezzo dell'anteprima, raddrizzato; NON legge un frame nuovo. #95: senza mappa esce grezzo (X-Raddrizzato: no)."""
|
||||||
|
corpo = corpo if isinstance(corpo, dict) else {}
|
||||||
|
nome = _sorgente(corpo.get("source"))
|
||||||
|
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")
|
||||||
|
with lock:
|
||||||
|
frame = grezzi.get(nome)
|
||||||
|
if frame is None:
|
||||||
|
raise HTTPException(404, "nessun frame: avviare l'anteprima o scattare")
|
||||||
|
attiva = archivio.attiva_di(nome)
|
||||||
|
if attiva is None:
|
||||||
|
raise HTTPException(409, "nessuna calibrazione attiva su questa sorgente")
|
||||||
|
mappa = archivio.leggi_mappa(nome)
|
||||||
|
if mappa is None: # #95: senza mappa (manuale o planare) solo scala: frame grezzo, nessuna correzione
|
||||||
|
dati = archivio.leggi(nome) or {}
|
||||||
|
cw, ch = dati.get("image_width"), dati.get("image_height")
|
||||||
|
alt, larg = frame.shape[:2]
|
||||||
|
if isinstance(cw, int) and isinstance(ch, int) and (cw, ch) != (larg, alt):
|
||||||
|
raise HTTPException(409, f"la calibrazione «{attiva}» è per immagini {cw}×{ch}, "
|
||||||
|
f"il frame è {larg}×{alt}")
|
||||||
|
corretto, raddrizzato = frame, "no"
|
||||||
|
else:
|
||||||
|
corretto = calibrazione.raddrizza(frame, mappa)
|
||||||
|
if corretto is None: # risoluzione diversa da quella calibrata
|
||||||
|
ch, cw = mappa.shape[:2]
|
||||||
|
alt, larg = frame.shape[:2]
|
||||||
|
raise HTTPException(409, f"la calibrazione «{attiva}» è per immagini {cw}×{ch}, "
|
||||||
|
f"il frame è {larg}×{alt}")
|
||||||
|
raddrizzato = "si"
|
||||||
|
alt, larg = corretto.shape[:2]
|
||||||
|
if corretto.dtype != np.uint8: # il JPEG vuole 8 bit
|
||||||
|
corretto = _grigio8(corretto)
|
||||||
|
scala = lato / max(alt, larg)
|
||||||
|
if scala < 1.0: # mai ingrandita
|
||||||
|
corretto = cv2.resize(corretto, (max(1, round(larg * scala)), max(1, round(alt * scala))),
|
||||||
|
interpolation=cv2.INTER_AREA)
|
||||||
|
ok, buf = cv2.imencode(".jpg", corretto, [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": raddrizzato, "X-Larghezza": str(larg), "X-Altezza": str(alt),
|
||||||
|
"X-Calibrazione": urllib.parse.quote(attiva, safe="")})
|
||||||
|
|
||||||
|
@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)
|
||||||
|
|
||||||
|
# ---- #110: specchiatura orizzontale/verticale per sorgente
|
||||||
|
|
||||||
|
def _orientamento_di(nome: str) -> dict:
|
||||||
|
with lock:
|
||||||
|
o = orientamenti.get(nome) or {}
|
||||||
|
return {"source": nome, "orizzontale": bool(o.get("orizzontale")), "verticale": bool(o.get("verticale"))}
|
||||||
|
|
||||||
|
@app.get("/camera/orientamento")
|
||||||
|
def orientamento_leggi(source: str = ""):
|
||||||
|
return _orientamento_di(_sorgente(source))
|
||||||
|
|
||||||
|
@app.put("/camera/orientamento")
|
||||||
|
def orientamento_scrivi(corpo: dict = Body(default=None)):
|
||||||
|
corpo = corpo if isinstance(corpo, dict) else {}
|
||||||
|
nome = _sorgente(corpo.get("source"))
|
||||||
|
orizz, vert = corpo.get("orizzontale"), corpo.get("verticale")
|
||||||
|
if not isinstance(orizz, bool) or not isinstance(vert, bool):
|
||||||
|
raise HTTPException(400, "«orizzontale» e «verticale» devono essere true o false")
|
||||||
|
with lock:
|
||||||
|
prima = orientamenti.get(nome) or {}
|
||||||
|
prima_voce = orientamenti.get(nome) # #110: voce di prima (None = non c'era), per il ripristino se il salvataggio fallisce
|
||||||
|
cambiato = (bool(prima.get("orizzontale")), bool(prima.get("verticale"))) != (orizz, vert)
|
||||||
|
if orizz or vert:
|
||||||
|
orientamenti[nome] = {"orizzontale": orizz, "verticale": vert}
|
||||||
|
else:
|
||||||
|
orientamenti.pop(nome, None) # tutti false: la sorgente esce dal file
|
||||||
|
# scrittura atomica di tutto il dizionario (tmp + replace)
|
||||||
|
try:
|
||||||
|
file_orientamento.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = file_orientamento.with_name(file_orientamento.name + ".tmp")
|
||||||
|
tmp.write_text(json.dumps(orientamenti, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||||
|
os.replace(tmp, file_orientamento)
|
||||||
|
except OSError as e:
|
||||||
|
_log.warning("orientamento non salvato (%s): %s", file_orientamento, e)
|
||||||
|
if prima_voce is None: # #110: salvataggio fallito -> in memoria resta l'orientamento di prima
|
||||||
|
orientamenti.pop(nome, None)
|
||||||
|
else:
|
||||||
|
orientamenti[nome] = prima_voce
|
||||||
|
raise HTTPException(500, "salvataggio dell'orientamento non riuscito")
|
||||||
|
# i frame tenuti hanno l'orientamento di prima
|
||||||
|
grezzi.pop(nome, None)
|
||||||
|
bloccati.pop(nome, None)
|
||||||
|
avviso = None
|
||||||
|
if cambiato and archivio.leggi_mappa(nome) is not None:
|
||||||
|
avviso = (f"la calibrazione attiva «{archivio.attiva_di(nome)}» corregge la distorsione "
|
||||||
|
"con l'orientamento di prima: va rifatta")
|
||||||
|
return {**_orientamento_di(nome), "avviso": avviso}
|
||||||
|
|
||||||
|
@app.get("/calibrazione", response_class=HTMLResponse)
|
||||||
|
@app.get("/acquisizione", response_class=HTMLResponse) # #87: stessa pagina
|
||||||
|
def calibrazione_pagina():
|
||||||
|
return HTMLResponse(PAGINA)
|
||||||
|
|
||||||
|
@app.get("/visore/{nome}")
|
||||||
|
def visore_file(nome: str):
|
||||||
|
"""#122: i file JS del <tmf-viewer> letti dal frontend Flask; la pagina resta offline."""
|
||||||
|
if nome not in _FILE_VISORE: # lista chiusa: niente percorsi dall'URL
|
||||||
|
raise HTTPException(404, "file non trovato")
|
||||||
|
for cartella in _CARTELLE_VISORE:
|
||||||
|
f = cartella / nome
|
||||||
|
if f.is_file():
|
||||||
|
return Response(f.read_bytes(), media_type="application/javascript", headers={"Cache-Control": "no-cache"})
|
||||||
|
raise HTTPException(404, "file non trovato")
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
import uvicorn
|
||||||
|
p = argparse.ArgumentParser(description="Servizio camera minimo di stazione (#72)")
|
||||||
|
p.add_argument("--host", default="127.0.0.1")
|
||||||
|
p.add_argument("--port", type=int, default=8765)
|
||||||
|
p.add_argument("--folder", default=None, help="cartella immagini per camera:folder")
|
||||||
|
p.add_argument("--config-dir", default=None,
|
||||||
|
help="cartella delle calibrazioni salvate (default: ProgramData su Windows)") # #83
|
||||||
|
a = p.parse_args()
|
||||||
|
uvicorn.run(create_app(a.folder, a.config_dir), host=a.host, port=a.port)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
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,92 @@
|
|||||||
|
"""Test di scripts/archivio_sorgente.py (TieMeasureFlow#109): repo temporaneo con un submodule finto.
|
||||||
|
|
||||||
|
Uso: uv run --no-project --python 3.13 --with pytest pytest -q scripts/tests/test_archivio_sorgente.py
|
||||||
|
"""
|
||||||
|
import importlib.util
|
||||||
|
import subprocess
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_spec = importlib.util.spec_from_file_location(
|
||||||
|
"archivio_sorgente", Path(__file__).resolve().parents[1] / "archivio_sorgente.py")
|
||||||
|
archivio = importlib.util.module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(archivio)
|
||||||
|
|
||||||
|
GIT = ["git", "-c", "core.autocrlf=false", "-c", "protocol.file.allow=always",
|
||||||
|
"-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"]
|
||||||
|
|
||||||
|
|
||||||
|
def _g(cartella, *args):
|
||||||
|
r = subprocess.run([*GIT, "-C", str(cartella), *args], capture_output=True, text=True)
|
||||||
|
assert r.returncode == 0, r.stderr
|
||||||
|
return r.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _repo(tmp_path, nome):
|
||||||
|
d = tmp_path / nome
|
||||||
|
d.mkdir()
|
||||||
|
_g(d, "init", "-q", "-b", "main")
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def padre(tmp_path):
|
||||||
|
"""Repo padre con vendor/visionsuite registrato (submodule finto da un repo locale)."""
|
||||||
|
origine = _repo(tmp_path, "origine")
|
||||||
|
(origine / "motore.py").write_text("x = 1\n")
|
||||||
|
_g(origine, "add", ".")
|
||||||
|
_g(origine, "commit", "-q", "-m", "motore")
|
||||||
|
p = _repo(tmp_path, "padre")
|
||||||
|
(p / "app.py").write_text("print('tmf')\n")
|
||||||
|
_g(p, "add", ".")
|
||||||
|
_g(p, "commit", "-q", "-m", "app")
|
||||||
|
_g(p, "submodule", "add", "-q", str(origine), "vendor/visionsuite")
|
||||||
|
_g(p, "commit", "-q", "-m", "submodule")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def test_zip_con_repo_submodule_e_versione(padre, tmp_path):
|
||||||
|
atteso = _g(padre, "ls-tree", "HEAD", "vendor/visionsuite").split()[2]
|
||||||
|
out = tmp_path / "o" / "tmf.zip"
|
||||||
|
assert archivio.crea_archivio(padre, out) == atteso
|
||||||
|
with zipfile.ZipFile(out) as z:
|
||||||
|
nomi = z.namelist()
|
||||||
|
assert "app.py" in nomi and "vendor/visionsuite/motore.py" in nomi
|
||||||
|
dati = z.read("vision-engine-version.txt")
|
||||||
|
assert dati == atteso.encode("ascii") and len(dati) == 40
|
||||||
|
assert not dati.startswith(b"\xef\xbb\xbf") and b"\n" not in dati # niente BOM, niente a capo
|
||||||
|
|
||||||
|
|
||||||
|
def test_sha_resta_quello_registrato_se_il_submodule_avanza(padre, tmp_path):
|
||||||
|
registrato = _g(padre, "ls-tree", "HEAD", "vendor/visionsuite").split()[2]
|
||||||
|
sub = padre / "vendor" / "visionsuite"
|
||||||
|
(sub / "nuovo.py").write_text("y = 2\n")
|
||||||
|
_g(sub, "add", ".")
|
||||||
|
_g(sub, "commit", "-q", "-m", "avanti non registrato")
|
||||||
|
assert _g(sub, "rev-parse", "HEAD") != registrato
|
||||||
|
out = tmp_path / "tmf.zip"
|
||||||
|
assert archivio.crea_archivio(padre, out) == registrato
|
||||||
|
with zipfile.ZipFile(out) as z:
|
||||||
|
assert z.read("vision-engine-version.txt").decode() == registrato
|
||||||
|
assert "vendor/visionsuite/nuovo.py" not in z.namelist() # contenuto del commit registrato
|
||||||
|
|
||||||
|
|
||||||
|
def test_submodule_non_inizializzato_errore_e_nessuno_zip(padre, tmp_path):
|
||||||
|
clone = tmp_path / "clone"
|
||||||
|
_g(tmp_path, "clone", "-q", str(padre), str(clone)) # senza --recurse-submodules
|
||||||
|
out = tmp_path / "tmf.zip"
|
||||||
|
with pytest.raises(archivio.ErroreArchivio, match="inizializzato"):
|
||||||
|
archivio.crea_archivio(clone, out)
|
||||||
|
assert not out.exists() and not list(tmp_path.glob("*.tmp")) # nessuno zip ne' temporaneo
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_esce_1_con_messaggio(padre, tmp_path, capsys, monkeypatch):
|
||||||
|
clone = tmp_path / "clone"
|
||||||
|
_g(tmp_path, "clone", "-q", str(padre), str(clone))
|
||||||
|
originale = archivio.crea_archivio
|
||||||
|
# la CLI lavora sul repo che contiene lo script: la si dirotta sul clone senza submodule
|
||||||
|
monkeypatch.setattr(archivio, "crea_archivio", lambda r, o, ref="HEAD": originale(clone, o, ref))
|
||||||
|
assert archivio.main(["--out", str(tmp_path / "x.zip")]) == 1
|
||||||
|
assert "ERRORE" in capsys.readouterr().err
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""Test di #94: «Blocca immagine» (scripts/camera_service.py) e riquadro vivo nella tab Calibrazione (PAGINA).
|
||||||
|
|
||||||
|
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
|
||||||
|
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
|
||||||
|
Sorgente finta che conta i grab: niente camera vera, niente ProgramData.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from test_calibrazione import _png, cartella, client, una_scacchiera # fixture e helper della calibrazione
|
||||||
|
from test_immagini_affiancate import _anteprima, _carica, _modulo
|
||||||
|
from test_pagina_acquisizione import PAGINA
|
||||||
|
|
||||||
|
__all__ = ["cartella", "client"] # le fixture importate restano usate
|
||||||
|
|
||||||
|
FINTA = "finta"
|
||||||
|
CAMPI = {"cols": "9", "rows": "6", "square_mm": "2"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def finta(client, monkeypatch):
|
||||||
|
"""Sorgente «finta»: il primo grab da' la scacchiera, i successivi un frame bianco (non calibrabile)."""
|
||||||
|
letture = []
|
||||||
|
scacchiera = una_scacchiera()
|
||||||
|
bianco = np.full(scacchiera.shape, 255, np.uint8)
|
||||||
|
|
||||||
|
class Finta:
|
||||||
|
def grab(self, timeout=1000):
|
||||||
|
letture.append(1)
|
||||||
|
return scacchiera.copy() if len(letture) == 1 else bianco.copy()
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
monkeypatch.setitem(_modulo(client), "open_source", lambda *a, **k: Finta())
|
||||||
|
client.letture = letture
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def _blocca(c, nome=FINTA):
|
||||||
|
return c.post("/camera/blocca", json={"source": nome})
|
||||||
|
|
||||||
|
|
||||||
|
def _calcola(c, **extra):
|
||||||
|
return c.post("/camera/calibrate", data={"source": FINTA, **CAMPI, **extra})
|
||||||
|
|
||||||
|
|
||||||
|
# ---- servizio
|
||||||
|
|
||||||
|
def test_blocca_senza_frame_404(finta):
|
||||||
|
r = _blocca(finta)
|
||||||
|
assert r.status_code == 404 and r.json()["detail"] == "nessun frame: avviare l'anteprima o scattare"
|
||||||
|
|
||||||
|
|
||||||
|
def test_blocca_senza_source_400(finta):
|
||||||
|
assert finta.post("/camera/blocca", json={}).status_code == 400
|
||||||
|
assert finta.post("/camera/blocca").status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_anteprima_poi_blocca_200_con_misure(finta):
|
||||||
|
assert _anteprima(finta, FINTA).status_code == 200
|
||||||
|
r = _blocca(finta)
|
||||||
|
alt, larg = una_scacchiera().shape[:2]
|
||||||
|
assert r.status_code == 200 and r.json() == {"source": FINTA, "larghezza": larg, "altezza": alt}
|
||||||
|
|
||||||
|
|
||||||
|
def test_calibrate_bloccata_usa_la_copia_senza_grab(finta):
|
||||||
|
assert _anteprima(finta, FINTA).status_code == 200 # frame 1: la scacchiera
|
||||||
|
assert _blocca(finta).status_code == 200
|
||||||
|
assert _anteprima(finta, FINTA).status_code == 200 # frame 2: bianco, il grezzo cambia ma la copia no
|
||||||
|
prima = len(finta.letture)
|
||||||
|
r = _calcola(finta, bloccata="true")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["bloccata"] is True and r.json()["origine"] == FINTA
|
||||||
|
assert len(finta.letture) == prima # nessun grab per il calcolo
|
||||||
|
# senza il blocco il calcolo legge un frame nuovo (bianco): non calibrabile
|
||||||
|
assert _calcola(finta).status_code == 422
|
||||||
|
assert len(finta.letture) == prima + 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_calibrate_normale_dice_bloccata_false(client):
|
||||||
|
r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["bloccata"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_calibrate_bloccata_senza_copia_409(finta):
|
||||||
|
assert _anteprima(finta, FINTA).status_code == 200 # il grezzo c'e', ma non e' bloccato
|
||||||
|
r = _calcola(finta, bloccata="true")
|
||||||
|
assert r.status_code == 409 and r.json()["detail"] == "nessuna immagine bloccata su questa sorgente"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sblocca_204_idempotente_e_toglie_la_copia(finta):
|
||||||
|
assert finta.delete("/camera/blocca", params={"source": FINTA}).status_code == 204 # niente da togliere
|
||||||
|
assert _anteprima(finta, FINTA).status_code == 200
|
||||||
|
assert _blocca(finta).status_code == 200
|
||||||
|
assert finta.delete("/camera/blocca", params={"source": FINTA}).status_code == 204
|
||||||
|
assert _calcola(finta, bloccata="true").status_code == 409
|
||||||
|
assert finta.delete("/camera/blocca", params={"source": FINTA}).status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
def test_rimuovere_l_immagine_toglie_la_copia_bloccata(client):
|
||||||
|
assert _carica(client, "foto.png", una_scacchiera()).status_code == 201
|
||||||
|
assert _anteprima(client, "foto.png").status_code == 200
|
||||||
|
assert _blocca(client, "foto.png").status_code == 200
|
||||||
|
assert client.delete("/camera/immagini", params={"nome": "foto.png"}).status_code == 204
|
||||||
|
assert _carica(client, "foto.png", una_scacchiera()).status_code == 201
|
||||||
|
r = client.post("/camera/calibrate", data={"source": "foto.png", "bloccata": "true", **CAMPI})
|
||||||
|
assert r.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
# ---- pagina (contenuto statico)
|
||||||
|
|
||||||
|
def test_pagina_riquadro_e_casella_nella_tab_calibrazione():
|
||||||
|
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
|
||||||
|
assert {"immagineCalTab", "vuotoCalTab", "scattaCal", "bloccaImg", "msgBlocco"} <= ids
|
||||||
|
assert "Nessuna immagine: «Scatta» o avviare l'anteprima nella tab Acquisizione" in PAGINA
|
||||||
|
assert "Blocca immagine" in PAGINA
|
||||||
|
tab = PAGINA.split('id="pannelloCal"')[1].split('id="sezioneSalvate"')[0]
|
||||||
|
assert "immagineCalTab" in tab and "bloccaImg" in tab # nella sezione Calibrazione, non altrove
|
||||||
|
|
||||||
|
|
||||||
|
def test_pagina_scegli_tab_non_ferma_piu_il_ciclo():
|
||||||
|
assert "A.riprendi" not in PAGINA
|
||||||
|
scegli = PAGINA.split("function scegliTab")[1].split("$(\"tabAcq\").onclick")[0]
|
||||||
|
assert "fermaAnteprima" not in scegli and "avviaAnteprima" not in scegli
|
||||||
|
|
||||||
|
|
||||||
|
def test_pagina_calcola_manda_bloccata_e_non_ferma_il_ciclo():
|
||||||
|
calcola = PAGINA.split("async function calcola")[1].split("async function salvaNome")[0]
|
||||||
|
assert 'f.append("bloccata", "true")' in calcola
|
||||||
|
assert "if (bloccata)" in calcola and "else fermaAnteprima();" in calcola
|
||||||
|
assert "immagine bloccata" in calcola
|
||||||
|
assert "l'anteprima resta sospesa" not in PAGINA and "si può bloccare l'immagine" in PAGINA
|
||||||
|
|
||||||
|
|
||||||
|
def test_pagina_blocco_rotte_e_cambio_sorgente():
|
||||||
|
assert '"/camera/blocca"' in PAGINA and '"/camera/blocca?source="' in PAGINA
|
||||||
|
assert "method: \"DELETE\"" in PAGINA
|
||||||
|
cambio = PAGINA.split("function cambioSorgente")[1].split("// ---- Anteprima")[0]
|
||||||
|
assert "rilasciaBlocco()" in cambio
|
||||||
|
assert cambio.index("rilasciaBlocco()") < cambio.index("svuotaRiquadri()")
|
||||||
|
|
||||||
|
|
||||||
|
def test_pagina_blocco_attende_l_anteprima_in_volo():
|
||||||
|
# #94: il frame bloccato e' quello mostrato: flag A.bloccando, attesa di A.inVolo (20 ms, max 3 s), poi POST, flag tolto in finally
|
||||||
|
blocco = PAGINA.split('$("bloccaImg").onchange')[1].split('$("avvia").onclick')[0]
|
||||||
|
assert "A.bloccando++" in blocco and "A.bloccando--" in blocco and "finally" in blocco
|
||||||
|
assert "k < 150" in blocco and "pausa(20)" in blocco
|
||||||
|
assert blocco.index("A.inVolo") < blocco.index('fetch("/camera/blocca"')
|
||||||
|
ciclo = PAGINA.split("async function ciclo")[1].split("function avviaAnteprima")[0]
|
||||||
|
# #111: il lato dell'anteprima segue lo zoom (latoAnteprima(), 1280 senza zoom)
|
||||||
|
assert "while (A.bloccando)" in ciclo and ciclo.index("A.bloccando") < ciclo.index("unaAnteprima(latoAnteprima())")
|
||||||
|
scatta = PAGINA.split("async function scatta")[1].split("// #94: «Blocca immagine»")[0]
|
||||||
|
assert "while (A.bloccando)" in scatta and scatta.index("A.bloccando") < scatta.index("unaAnteprima(4096)")
|
||||||
@@ -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", "bloccata"} == set(corpo) # #94: + bloccata; #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", "bloccata"} == 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,148 @@
|
|||||||
|
"""Test di #95: calibrazione manuale (mm/px impostato a mano) in scripts/camera_service.py e nella PAGINA.
|
||||||
|
|
||||||
|
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
|
||||||
|
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
|
||||||
|
Sorgenti = immagini caricate in tmp_path: niente camera vera, niente ProgramData.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from test_calibrazione import _png, cartella, client # fixture della calibrazione
|
||||||
|
from test_immagini_affiancate import _anteprima, _calibrata, _carica
|
||||||
|
from test_pagina_acquisizione import PAGINA
|
||||||
|
|
||||||
|
__all__ = ["cartella", "client"] # le fixture importate restano usate
|
||||||
|
|
||||||
|
SRC = "banco.png" # 64x48
|
||||||
|
ALTRA = "grande.png" # 80x60, risoluzione diversa
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sorgenti(client):
|
||||||
|
assert _carica(client, SRC, np.full((48, 64), 120, np.uint8)).status_code == 201
|
||||||
|
assert _carica(client, ALTRA, np.full((60, 80), 120, np.uint8)).status_code == 201
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def _manuale(c, nome="Manuale", mm=0.05, source=SRC, **extra):
|
||||||
|
return c.post("/camera/calibrazioni/manuale",
|
||||||
|
json={"source": source, "nome": nome, "mm_per_px": mm, **extra})
|
||||||
|
|
||||||
|
|
||||||
|
# ---- servizio
|
||||||
|
|
||||||
|
def test_manuale_201_con_scala_e_risoluzione_del_frame(sorgenti):
|
||||||
|
r = _manuale(sorgenti, mm=0.05)
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
m = r.json()
|
||||||
|
assert m["nome"] == "Manuale" and m["metodo"] == "manuale"
|
||||||
|
assert m["px_per_mm"] == pytest.approx(20.0) and m["mm_per_px"] == pytest.approx(0.05)
|
||||||
|
assert (m["image_width"], m["image_height"]) == (64, 48)
|
||||||
|
assert m["origine"] == SRC and m["square_mm"] is None and m["attiva_su"] == [] # non la attiva
|
||||||
|
voci = sorgenti.get("/camera/calibrazioni").json()["calibrazioni"]
|
||||||
|
assert [v["nome"] for v in voci] == ["Manuale"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_manuale_usa_il_frame_tenuto_se_c_e(sorgenti):
|
||||||
|
assert _anteprima(sorgenti, SRC).status_code == 200 # tiene il grezzo 64x48
|
||||||
|
r = _manuale(sorgenti)
|
||||||
|
assert r.status_code == 201 and (r.json()["image_width"], r.json()["image_height"]) == (64, 48)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("valore", [0, -1, 0.000001, 100.5, "abc", None, True])
|
||||||
|
def test_manuale_mm_non_valido_400(sorgenti, valore):
|
||||||
|
r = _manuale(sorgenti, mm=valore)
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert "mm/px deve essere un numero positivo" in r.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_manuale_senza_source_o_nome_400_e_sorgente_ignota_404(sorgenti):
|
||||||
|
assert sorgenti.post("/camera/calibrazioni/manuale", json={"nome": "x", "mm_per_px": 0.1}).status_code == 400
|
||||||
|
assert _manuale(sorgenti, nome=" ").status_code == 400
|
||||||
|
assert _manuale(sorgenti, source="nonesiste.png").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_manuale_409_nome_doppio_e_sovrascrivi(sorgenti):
|
||||||
|
assert _manuale(sorgenti, mm=0.05).status_code == 201
|
||||||
|
r = _manuale(sorgenti, mm=0.1)
|
||||||
|
assert r.status_code == 409 and "esiste già una calibrazione" in r.json()["detail"]
|
||||||
|
r = _manuale(sorgenti, mm=0.1, sovrascrivi=True)
|
||||||
|
assert r.status_code == 201 and r.json()["px_per_mm"] == pytest.approx(10.0)
|
||||||
|
assert _manuale(sorgenti, mm=0.1, sovrascrivi="si").status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_attiva_manuale_ok_su_stessa_risoluzione_e_legge_px_per_mm(sorgenti):
|
||||||
|
assert _manuale(sorgenti, mm=0.05).status_code == 201
|
||||||
|
r = sorgenti.put("/camera/calibrazione-attiva", json={"source": SRC, "nome": "Manuale"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
g = sorgenti.get("/camera/calibration", params={"source": SRC})
|
||||||
|
assert g.status_code == 200 and g.json()["px_per_mm"] == pytest.approx(20.0)
|
||||||
|
assert g.json()["nome"] == "Manuale" and g.json()["metodo"] == "manuale"
|
||||||
|
|
||||||
|
|
||||||
|
def test_attiva_manuale_409_su_risoluzione_diversa(sorgenti):
|
||||||
|
assert _manuale(sorgenti).status_code == 201
|
||||||
|
r = sorgenti.put("/camera/calibrazione-attiva", json={"source": ALTRA, "nome": "Manuale"})
|
||||||
|
assert r.status_code == 409 and "64×48" in r.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_anteprima_calibrata_con_manuale_200_grezza(sorgenti):
|
||||||
|
assert _manuale(sorgenti).status_code == 201
|
||||||
|
assert sorgenti.put("/camera/calibrazione-attiva", json={"source": SRC, "nome": "Manuale"}).status_code == 200
|
||||||
|
assert _anteprima(sorgenti, SRC).status_code == 200
|
||||||
|
r = _calibrata(sorgenti, SRC)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.headers["X-Raddrizzato"] == "no" and r.headers["X-Calibrazione"] == "Manuale"
|
||||||
|
assert (r.headers["X-Larghezza"], r.headers["X-Altezza"]) == ("64", "48")
|
||||||
|
assert r.headers["content-type"] == "image/jpeg"
|
||||||
|
|
||||||
|
|
||||||
|
def test_anteprima_calibrata_manuale_409_se_il_frame_ha_altra_risoluzione(sorgenti):
|
||||||
|
assert _manuale(sorgenti).status_code == 201
|
||||||
|
assert sorgenti.put("/camera/calibrazione-attiva", json={"source": SRC, "nome": "Manuale"}).status_code == 200
|
||||||
|
assert _anteprima(sorgenti, ALTRA).status_code == 200
|
||||||
|
# stessa sorgente attiva, ma il frame tenuto cambia (immagine sostituita con altra misura)
|
||||||
|
assert _carica(sorgenti, SRC, np.full((60, 80), 9, np.uint8)).status_code == 201
|
||||||
|
assert _anteprima(sorgenti, SRC).status_code == 200
|
||||||
|
r = _calibrata(sorgenti, SRC)
|
||||||
|
assert r.status_code == 409 and "64×48" in r.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---- pagina
|
||||||
|
|
||||||
|
def test_pagina_scheda_manuale_id_e_testi():
|
||||||
|
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
|
||||||
|
assert {"mmPx", "pxMmCalc", "nomeManuale", "salvaManuale", "msgManuale"} <= ids
|
||||||
|
tab = PAGINA.split('id="pannelloCal"')[1]
|
||||||
|
assert tab.index('id="sezioneCalibrazione"') < tab.index('id="sezioneManuale"') < tab.index('id="sezioneSalvate"')
|
||||||
|
assert "Calibrazione manuale" in PAGINA and "mm per pixel" in PAGINA
|
||||||
|
assert "salvata: per usarla «Carica» o CONFERMA" in PAGINA
|
||||||
|
assert "Sovrascrivi «" in PAGINA.split("async function salvaManuale")[1] # stessa conferma del Salva scacchiera
|
||||||
|
assert "/camera/calibrazioni/manuale" in PAGINA and "alert(" not in PAGINA and "confirm(" not in PAGINA
|
||||||
|
|
||||||
|
|
||||||
|
def test_pagina_manuale_senza_sorgente_pulsante_spento_con_nota():
|
||||||
|
stato = PAGINA.split("function statoManuale")[1].split("function aggiornaManuale")[0]
|
||||||
|
assert 'salvaManuale").disabled = !s' in stato and "Scegliere una sorgente" in stato
|
||||||
|
assert "statoManuale()" in PAGINA.split("async function aggiornaStato")[1].split("async function caricaCalibrazioni")[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_pagina_px_mm_calcolato_a_ogni_tasto():
|
||||||
|
assert '$("mmPx").oninput = aggiornaManuale' in PAGINA
|
||||||
|
assert '"= " + fmt(1 / v, 4) + " px/mm"' in PAGINA
|
||||||
|
|
||||||
|
|
||||||
|
def test_pagina_metodo_manuale_e_campi_null():
|
||||||
|
assert 'c.metodo === "densa" || c.metodo === "manuale" ? c.metodo : "planare"' in PAGINA
|
||||||
|
righe = PAGINA.split("function righe")[1].split("function tabella")[0]
|
||||||
|
assert 'd.metodo === "manuale"' in righe and "Lato dello scacco" in righe # la manuale ha il suo elenco, le altre il solito
|
||||||
|
assert 'v === null || v === undefined' in PAGINA.split("function fmt")[1].split("function fmtCasella")[0]
|
||||||
|
stato = PAGINA.split("async function aggiornaStato")[1].split("async function caricaCalibrazioni")[0]
|
||||||
|
assert "Number(d.square_mm) > 0" in stato
|
||||||
|
|
||||||
|
|
||||||
|
def test_pagina_calibrata_senza_raddrizzamento_dice_solo_scala():
|
||||||
|
assert "solo scala, nessuna correzione ottica" in PAGINA
|
||||||
|
assert 'r.headers.get("X-Raddrizzato") === "no"' in PAGINA
|
||||||
@@ -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,186 @@
|
|||||||
|
"""Test della specchiatura orizzontale/verticale per sorgente (#110): 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
|
||||||
|
Copre: GET/PUT /camera/orientamento, effetto su /camera/grab e /camera/anteprima,
|
||||||
|
persistenza al riavvio, file rotto, validazione, scarto dei frame tenuti, avviso
|
||||||
|
sulla calibrazione densa. Sorgente: camera:folder su un'immagine 4x3 asimmetrica.
|
||||||
|
"""
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from test_calibrazione import ( # calibrazione densa reale (#84), per l'avviso
|
||||||
|
SORGENTE as SORGENTE_DENSA, cartella_densa, carica_su, client_denso, salva_con_nome,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = ["cartella_densa", "client_denso"] # fixture importate, restano usate
|
||||||
|
|
||||||
|
MODULO = Path(__file__).resolve().parents[1] / "camera_service.py"
|
||||||
|
SORGENTE = "camera:folder"
|
||||||
|
|
||||||
|
|
||||||
|
def _carica():
|
||||||
|
spec = importlib.util.spec_from_file_location("camera_service_orient", MODULO)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def cartella(tmp_path):
|
||||||
|
# #110: 4x3 tutta nera con UN pixel acceso in alto a sinistra (riga 0, colonna 0)
|
||||||
|
img = tmp_path / "foto"
|
||||||
|
img.mkdir()
|
||||||
|
f = np.zeros((3, 4, 3), np.uint8)
|
||||||
|
f[0, 0] = 255
|
||||||
|
cv2.imwrite(str(img / "a.png"), f)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def config(tmp_path):
|
||||||
|
return tmp_path / "cfg"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(cartella, config):
|
||||||
|
mod = _carica()
|
||||||
|
with TestClient(mod.create_app(cartella, config)) as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
def _acceso(png: bytes):
|
||||||
|
"""(riga, colonna) del pixel acceso nel PNG; controlla che sia uno solo."""
|
||||||
|
f = cv2.imdecode(np.frombuffer(png, np.uint8), cv2.IMREAD_COLOR)
|
||||||
|
assert f.shape[:2] == (3, 4)
|
||||||
|
r, c = np.nonzero(f.max(axis=2) > 128)
|
||||||
|
assert len(r) == 1
|
||||||
|
return int(r[0]), int(c[0])
|
||||||
|
|
||||||
|
|
||||||
|
def _imposta(c, orizz, vert, source=SORGENTE):
|
||||||
|
return c.put("/camera/orientamento", json={"source": source, "orizzontale": orizz, "verticale": vert})
|
||||||
|
|
||||||
|
|
||||||
|
def _grab(c):
|
||||||
|
r = c.post("/camera/grab", json={"source": SORGENTE})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
return _acceso(r.content)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_senza_file_false_false(client):
|
||||||
|
r = client.get("/camera/orientamento", params={"source": SORGENTE})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"source": SORGENTE, "orizzontale": False, "verticale": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_senza_source_400(client):
|
||||||
|
assert client.get("/camera/orientamento").status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("orizz,vert,atteso", [
|
||||||
|
(False, False, (0, 0)), # originale: alto a sinistra
|
||||||
|
(True, False, (0, 3)), # orizzontale: alto a destra
|
||||||
|
(False, True, (2, 0)), # verticale: basso a sinistra
|
||||||
|
(True, True, (2, 3)), # entrambi: basso a destra (180 gradi)
|
||||||
|
])
|
||||||
|
def test_grab_specchiato(client, orizz, vert, atteso):
|
||||||
|
r = _imposta(client, orizz, vert)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert (r.json()["orizzontale"], r.json()["verticale"]) == (orizz, vert)
|
||||||
|
assert client.get("/camera/orientamento", params={"source": SORGENTE}).json()["orizzontale"] is orizz
|
||||||
|
assert _grab(client) == atteso
|
||||||
|
|
||||||
|
|
||||||
|
def test_tutti_false_toglie_la_sorgente_dal_file(client, config):
|
||||||
|
_imposta(client, True, False)
|
||||||
|
assert SORGENTE in (config / "orientamento.json").read_text(encoding="utf-8")
|
||||||
|
_imposta(client, False, False)
|
||||||
|
assert SORGENTE not in (config / "orientamento.json").read_text(encoding="utf-8")
|
||||||
|
assert _grab(client) == (0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_anteprima_specchiata_dimensioni_invariate(client):
|
||||||
|
_imposta(client, True, True)
|
||||||
|
r = client.post("/camera/anteprima", json={"source": SORGENTE, "lato_max": 4096})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert (r.headers["x-larghezza"], r.headers["x-altezza"]) == ("4", "3")
|
||||||
|
f = cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_GRAYSCALE)
|
||||||
|
assert f.shape == (3, 4)
|
||||||
|
# JPEG con perdite: il pixel acceso e' il piu' chiaro, in basso a destra
|
||||||
|
assert np.unravel_index(int(np.argmax(f)), f.shape) == (2, 3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_persistenza_al_riavvio(cartella, config):
|
||||||
|
mod = _carica()
|
||||||
|
with TestClient(mod.create_app(cartella, config)) as c1:
|
||||||
|
assert _imposta(c1, True, False).status_code == 200
|
||||||
|
with TestClient(_carica().create_app(cartella, config)) as c2:
|
||||||
|
j = c2.get("/camera/orientamento", params={"source": SORGENTE}).json()
|
||||||
|
assert (j["orizzontale"], j["verticale"]) == (True, False)
|
||||||
|
assert _grab(c2) == (0, 3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_rotto_non_blocca_l_avvio(cartella, config):
|
||||||
|
config.mkdir(parents=True)
|
||||||
|
(config / "orientamento.json").write_text("{non e json", encoding="utf-8")
|
||||||
|
with TestClient(_carica().create_app(cartella, config)) as c:
|
||||||
|
j = c.get("/camera/orientamento", params={"source": SORGENTE}).json()
|
||||||
|
assert (j["orizzontale"], j["verticale"]) == (False, False)
|
||||||
|
assert _imposta(c, False, True).status_code == 200 # e si puo' riscrivere
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_campi_non_bool_400(client):
|
||||||
|
for corpo in ({"source": SORGENTE, "orizzontale": 1, "verticale": False},
|
||||||
|
{"source": SORGENTE, "orizzontale": True, "verticale": "si"},
|
||||||
|
{"source": SORGENTE, "orizzontale": True},
|
||||||
|
{"orizzontale": True, "verticale": True}):
|
||||||
|
assert client.put("/camera/orientamento", json=corpo).status_code == 400, corpo
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_scarta_i_frame_tenuti(client):
|
||||||
|
assert client.post("/camera/anteprima", json={"source": SORGENTE}).status_code == 200
|
||||||
|
assert client.post("/camera/blocca", json={"source": SORGENTE}).status_code == 200
|
||||||
|
_imposta(client, True, False)
|
||||||
|
assert client.post("/camera/blocca", json={"source": SORGENTE}).status_code == 404
|
||||||
|
# col frame tenuto scartato anche l'ultimo grezzo dell'anteprima e' sparito
|
||||||
|
assert client.post("/camera/anteprima/calibrata", json={"source": SORGENTE}).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_avviso_senza_mappa_e_null(client):
|
||||||
|
j = _imposta(client, True, False).json()
|
||||||
|
assert j["avviso"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_avviso_con_mappa_attiva(client_denso):
|
||||||
|
densa = client_denso.post("/camera/calibrate", data={"source": SORGENTE_DENSA, "square_mm": "2"}).json()
|
||||||
|
assert salva_con_nome(client_denso, densa, "Densa").json()["metodo"] == "densa"
|
||||||
|
assert carica_su(client_denso, SORGENTE_DENSA, "Densa").status_code == 200
|
||||||
|
r = _imposta(client_denso, True, False, source=SORGENTE_DENSA)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert "«Densa»" in r.json()["avviso"] and "va rifatta" in r.json()["avviso"]
|
||||||
|
# stesso valore di nuovo: nulla e' cambiato, nessun avviso
|
||||||
|
assert _imposta(client_denso, True, False, source=SORGENTE_DENSA).json()["avviso"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_scrittura_fallita_500_e_orientamento_di_prima(cartella, config, monkeypatch):
|
||||||
|
# #110: se orientamento.json non si scrive, in memoria resta l'orientamento di prima
|
||||||
|
mod = _carica()
|
||||||
|
with TestClient(mod.create_app(cartella, config)) as c:
|
||||||
|
assert _imposta(c, True, False).status_code == 200 # stato di partenza: orizzontale
|
||||||
|
def rotto(*a, **k):
|
||||||
|
raise OSError("disco pieno")
|
||||||
|
monkeypatch.setattr(mod.os, "replace", rotto)
|
||||||
|
assert _imposta(c, False, True).status_code == 500
|
||||||
|
j = c.get("/camera/orientamento", params={"source": SORGENTE}).json()
|
||||||
|
assert (j["orizzontale"], j["verticale"]) == (True, False)
|
||||||
|
assert _grab(c) == (0, 3) # ancora solo orizzontale
|
||||||
|
# sorgente che prima non c'era: la voce non resta
|
||||||
|
assert c.put("/camera/orientamento", json={"source": "altra", "orizzontale": True, "verticale": True}).status_code == 500
|
||||||
|
j = c.get("/camera/orientamento", params={"source": "altra"}).json()
|
||||||
|
assert (j["orizzontale"], j["verticale"]) == (False, False)
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""Test di scripts/camera_service.py (#72).
|
||||||
|
|
||||||
|
Uso: vendor/visionsuite/.venv/bin/python -m pytest scripts/tests/test_camera_service.py -q
|
||||||
|
Dipende da: venv di VisionSuite (vs-camera, fastapi, httpx, opencv, pytest).
|
||||||
|
"""
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
MODULO = Path(__file__).resolve().parents[1] / "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
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def cartella(tmp_path):
|
||||||
|
for nome, (w, h) in {"a.png": (64, 48), "b.png": (64, 48)}.items():
|
||||||
|
cv2.imwrite(str(tmp_path / nome), np.full((h, w, 3), 128, np.uint8))
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(cartella):
|
||||||
|
mod = _carica()
|
||||||
|
with TestClient(mod.create_app(cartella)) as c:
|
||||||
|
c.mod = mod
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
def test_sources_contiene_folder(client):
|
||||||
|
r = client.get("/camera/sources")
|
||||||
|
assert r.status_code == 200
|
||||||
|
nomi = [s["name"] for s in r.json()["sources"]]
|
||||||
|
assert "camera:folder" in nomi
|
||||||
|
voce = next(s for s in r.json()["sources"] if s["name"] == "camera:folder")
|
||||||
|
assert {"name", "kind", "label"} <= set(voce)
|
||||||
|
|
||||||
|
|
||||||
|
def test_grab_folder_restituisce_png(client):
|
||||||
|
r = client.post("/camera/grab", json={"source": "camera:folder"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.headers["content-type"] == "image/png"
|
||||||
|
img = cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_COLOR)
|
||||||
|
assert img.shape[:2] == (48, 64)
|
||||||
|
|
||||||
|
|
||||||
|
def test_grab_ripetuto_in_loop(client):
|
||||||
|
# due immagini in cartella, tre grab: il loop non deve esaurirsi
|
||||||
|
for _ in range(3):
|
||||||
|
assert client.post("/camera/grab", json={"source": "camera:folder"}).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_mancante_400(client):
|
||||||
|
assert client.post("/camera/grab", json={}).status_code == 400
|
||||||
|
assert client.post("/camera/grab", json={"source": " "}).status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_inesistente_404(client):
|
||||||
|
assert client.post("/camera/grab", json={"source": "camera:non-esiste"}).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_timeout_non_valido_400(client):
|
||||||
|
r = client.post("/camera/grab", json={"source": "camera:folder", "timeout_ms": 0})
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_errore_camera_503_tradotto(client, monkeypatch):
|
||||||
|
from visionsuite.camera.camera_interface import CameraGrabError
|
||||||
|
|
||||||
|
class Rotta:
|
||||||
|
def grab(self, timeout):
|
||||||
|
raise CameraGrabError("Camera is not open")
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
monkeypatch.setattr(client.mod, "open_source", lambda *a, **k: Rotta())
|
||||||
|
r = client.post("/camera/grab", json={"source": "camera:finta"})
|
||||||
|
assert r.status_code == 503
|
||||||
|
assert r.json()["detail"] == "La camera non è aperta"
|
||||||
|
|
||||||
|
|
||||||
|
def test_timeout_504(client, monkeypatch):
|
||||||
|
class Lenta:
|
||||||
|
def grab(self, timeout):
|
||||||
|
raise TimeoutError("scaduto")
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
monkeypatch.setattr(client.mod, "open_source", lambda *a, **k: Lenta())
|
||||||
|
r = client.post("/camera/grab", json={"source": "camera:lenta"})
|
||||||
|
assert r.status_code == 504
|
||||||
|
|
||||||
|
|
||||||
|
def test_sorgente_aperta_una_volta(client, monkeypatch):
|
||||||
|
aperture = []
|
||||||
|
reale = client.mod.open_source
|
||||||
|
|
||||||
|
def conta(*a, **k):
|
||||||
|
aperture.append(a[0])
|
||||||
|
return reale(*a, **k)
|
||||||
|
|
||||||
|
monkeypatch.setattr(client.mod, "open_source", conta)
|
||||||
|
for _ in range(3):
|
||||||
|
client.post("/camera/grab", json={"source": "camera:folder"})
|
||||||
|
assert aperture == ["camera:folder"]
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
"""Test di #92: immagini caricate come sorgenti e anteprima calibrata (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
|
||||||
|
Solo tmp_path: niente ProgramData, niente camera vera. Le immagini le scrive il test.
|
||||||
|
"""
|
||||||
|
from urllib.parse import quote, unquote
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from test_calibrazione import ( # fixture e helper della calibrazione (#83/#84)
|
||||||
|
CAMPI, _png, carica_su, cartella, client, salva_con_nome, una_scacchiera,
|
||||||
|
una_scacchiera_densa,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = ["cartella", "client"] # le fixture importate restano usate
|
||||||
|
|
||||||
|
DENSA = "densa.png"
|
||||||
|
PIANA = "piana.png"
|
||||||
|
|
||||||
|
|
||||||
|
def _modulo(c) -> dict:
|
||||||
|
"""Il dict globale di camera_service (per sostituire open_source con una spia)."""
|
||||||
|
return next(r.endpoint for r in c.app.routes if r.path == "/camera/grab").__globals__
|
||||||
|
|
||||||
|
|
||||||
|
def _carica(c, nome: str, img: np.ndarray, **extra):
|
||||||
|
"""POST /camera/immagini con un'immagine codificata PNG (o il contenuto dato)."""
|
||||||
|
return c.post("/camera/immagini", files={"image": (nome, _png(img), "image/png")}, **extra)
|
||||||
|
|
||||||
|
|
||||||
|
def _densa(**kw) -> np.ndarray:
|
||||||
|
return una_scacchiera_densa(k=1e-7, **kw)
|
||||||
|
|
||||||
|
|
||||||
|
def _calibra_e_attiva(c, nome_immagine: str, nome_calibrazione: str, **campi):
|
||||||
|
"""Calcola dalla sorgente, salva con nome e la rende attiva su quella sorgente."""
|
||||||
|
r = c.post("/camera/calibrate", data={"source": nome_immagine, "square_mm": "2", **campi})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert salva_con_nome(c, r.json(), nome_calibrazione).status_code == 201
|
||||||
|
assert carica_su(c, nome_immagine, nome_calibrazione).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def _anteprima(c, nome: str, **extra):
|
||||||
|
return c.post("/camera/anteprima", json={"source": nome, "grezzo": True, **extra})
|
||||||
|
|
||||||
|
|
||||||
|
def _calibrata(c, nome: str, **extra):
|
||||||
|
return c.post("/camera/anteprima/calibrata", json={"source": nome, **extra})
|
||||||
|
|
||||||
|
|
||||||
|
# ---- POST /camera/immagini
|
||||||
|
|
||||||
|
def test_carica_201_salva_in_cartella_immagini(client):
|
||||||
|
r = _carica(client, "calibration_135_1.bmp", una_scacchiera())
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
assert r.json() == {"source": "calibration_135_1.bmp",
|
||||||
|
"label": "immagine — calibration_135_1.bmp", "kind": "image"}
|
||||||
|
assert (client.config / "immagini" / "calibration_135_1.bmp").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def test_carica_ripulisce_il_nome_e_toglie_il_percorso(client):
|
||||||
|
r = _carica(client, "C:\\foto\\ban co è1.PNG", una_scacchiera())
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
assert r.json()["source"] == "ban_co_1.PNG" # niente percorso, caratteri sicuri, estensione intatta
|
||||||
|
assert (client.config / "immagini" / "ban_co_1.PNG").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def test_carica_stesso_nome_sostituisce(client):
|
||||||
|
_carica(client, "a.png", np.full((48, 64), 10, np.uint8))
|
||||||
|
assert _carica(client, "a.png", np.full((30, 40), 200, np.uint8)).status_code == 201
|
||||||
|
assert [f.name for f in (client.config / "immagini").iterdir()] == ["a.png"]
|
||||||
|
img = cv2.imread(str(client.config / "immagini" / "a.png"), cv2.IMREAD_UNCHANGED)
|
||||||
|
assert img.shape[:2] == (30, 40)
|
||||||
|
|
||||||
|
|
||||||
|
def test_carica_senza_file_400(client):
|
||||||
|
assert client.post("/camera/immagini").status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_carica_estensione_non_ammessa_400(client):
|
||||||
|
r = client.post("/camera/immagini", files={"image": ("note.txt", b"ciao", "text/plain")})
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert not (client.config / "immagini").exists() or not list((client.config / "immagini").iterdir())
|
||||||
|
|
||||||
|
|
||||||
|
def test_carica_file_illeggibile_422_e_niente_su_disco(client):
|
||||||
|
r = client.post("/camera/immagini", files={"image": ("rotta.png", b"non e una immagine", "image/png")})
|
||||||
|
assert r.status_code == 422
|
||||||
|
assert not (client.config / "immagini").exists() or not list((client.config / "immagini").iterdir())
|
||||||
|
|
||||||
|
|
||||||
|
# ---- GET /camera/sources
|
||||||
|
|
||||||
|
def test_sources_con_immagini_e_senza_camera_file(client):
|
||||||
|
_carica(client, "b.png", una_scacchiera())
|
||||||
|
_carica(client, "a.png", una_scacchiera())
|
||||||
|
voci = client.get("/camera/sources").json()["sources"]
|
||||||
|
nomi = [v["name"] for v in voci]
|
||||||
|
assert "camera:file" not in nomi
|
||||||
|
assert "camera:folder" in nomi
|
||||||
|
assert nomi[-2:] == ["a.png", "b.png"] # le immagini stanno in coda
|
||||||
|
assert voci[-1] == {"name": "b.png", "kind": "image", "label": "immagine — b.png"}
|
||||||
|
assert nomi.index("camera:folder") < nomi.index("a.png")
|
||||||
|
|
||||||
|
|
||||||
|
def test_sources_camere_in_cache_e_aggiorna(client):
|
||||||
|
# #92: la ricerca camere (~10 s) si fa una volta e resta 60 s; aggiorna=true la rilancia; le immagini sono sempre fresche
|
||||||
|
g = _modulo(client)
|
||||||
|
originale = g["list_sources"]
|
||||||
|
chiamate = []
|
||||||
|
|
||||||
|
def finto(cartella, cameras=True, **kw):
|
||||||
|
if cameras:
|
||||||
|
chiamate.append(1)
|
||||||
|
return originale(cartella, cameras=cameras, **kw)
|
||||||
|
|
||||||
|
g["list_sources"] = finto
|
||||||
|
try:
|
||||||
|
client.get("/camera/sources") # se la ricerca d'avvio e' ancora in corso aspetta il suo lock
|
||||||
|
base = len(chiamate)
|
||||||
|
_carica(client, "nuova.png", una_scacchiera())
|
||||||
|
voci = client.get("/camera/sources").json()["sources"]
|
||||||
|
client.get("/camera/sources")
|
||||||
|
assert len(chiamate) == base # seconda e terza chiamata: nessuna nuova ricerca
|
||||||
|
assert voci[-1]["name"] == "nuova.png" # ma l'immagine appena caricata c'e'
|
||||||
|
client.get("/camera/sources", params={"aggiorna": "true"})
|
||||||
|
assert len(chiamate) == base + 1 # aggiorna=true la rilancia
|
||||||
|
client.get("/camera/sources")
|
||||||
|
assert len(chiamate) == base + 1 # e il risultato nuovo torna in cache
|
||||||
|
finally:
|
||||||
|
g["list_sources"] = originale
|
||||||
|
|
||||||
|
|
||||||
|
def test_sources_senza_immagini_non_crea_la_cartella(client):
|
||||||
|
voci = client.get("/camera/sources").json()["sources"]
|
||||||
|
assert all(v["kind"] != "image" for v in voci)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- grab / anteprima / calibrate su un'immagine caricata
|
||||||
|
|
||||||
|
def test_grab_anteprima_e_grab_ripetuti_su_immagine(client):
|
||||||
|
_carica(client, "foto.png", una_scacchiera())
|
||||||
|
for _ in range(3): # l'anteprima continua non deve esaurire un'immagine sola
|
||||||
|
r = client.post("/camera/grab", json={"source": "foto.png"})
|
||||||
|
assert r.status_code == 200 and r.headers["content-type"] == "image/png"
|
||||||
|
for _ in range(3):
|
||||||
|
r = _anteprima(client, "foto.png")
|
||||||
|
assert r.status_code == 200 and r.headers["content-type"] == "image/jpeg"
|
||||||
|
assert (r.headers["X-Larghezza"], r.headers["X-Altezza"]) == ("520", "400")
|
||||||
|
|
||||||
|
|
||||||
|
def test_grab_immagine_inesistente_404(client):
|
||||||
|
assert client.post("/camera/grab", json={"source": "non_c_e.png"}).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_calibrate_su_immagine_origine_e_il_nome(client):
|
||||||
|
_carica(client, "calibration_135_1.bmp", una_scacchiera())
|
||||||
|
r = client.post("/camera/calibrate", data={"source": "calibration_135_1.bmp", **CAMPI})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["origine"] == "calibration_135_1.bmp"
|
||||||
|
|
||||||
|
|
||||||
|
# ---- DELETE /camera/immagini
|
||||||
|
|
||||||
|
def test_elimina_204_poi_404_e_sparisce_dalle_sorgenti(client):
|
||||||
|
_carica(client, "foto.png", una_scacchiera())
|
||||||
|
assert client.delete("/camera/immagini", params={"nome": "foto.png"}).status_code == 204
|
||||||
|
assert not (client.config / "immagini" / "foto.png").exists()
|
||||||
|
assert client.delete("/camera/immagini", params={"nome": "foto.png"}).status_code == 404
|
||||||
|
assert "foto.png" not in [v["name"] for v in client.get("/camera/sources").json()["sources"]]
|
||||||
|
assert client.post("/camera/grab", json={"source": "foto.png"}).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_elimina_nome_con_percorso_404(client):
|
||||||
|
_carica(client, "foto.png", una_scacchiera())
|
||||||
|
assert client.delete("/camera/immagini", params={"nome": "../immagini/foto.png"}).status_code == 404
|
||||||
|
assert client.delete("/camera/immagini").status_code == 404
|
||||||
|
assert (client.config / "immagini" / "foto.png").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def test_elimina_chiude_la_sorgente_aperta(client, monkeypatch):
|
||||||
|
chiuse = []
|
||||||
|
modulo = _modulo(client)
|
||||||
|
reale = modulo["open_source"]
|
||||||
|
|
||||||
|
def spia(*a, **k):
|
||||||
|
s = reale(*a, **k)
|
||||||
|
chiusura = s.close
|
||||||
|
s.close = lambda: (chiuse.append(a[0]), chiusura())[1]
|
||||||
|
return s
|
||||||
|
|
||||||
|
monkeypatch.setitem(modulo, "open_source", spia)
|
||||||
|
_carica(client, "foto.png", una_scacchiera())
|
||||||
|
assert client.post("/camera/grab", json={"source": "foto.png"}).status_code == 200
|
||||||
|
assert client.delete("/camera/immagini", params={"nome": "foto.png"}).status_code == 204
|
||||||
|
assert chiuse == ["foto.png"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---- POST /camera/anteprima/calibrata
|
||||||
|
|
||||||
|
def test_calibrata_senza_frame_404(client):
|
||||||
|
_carica(client, DENSA, _densa())
|
||||||
|
r = _calibrata(client, DENSA)
|
||||||
|
assert r.status_code == 404 and r.json()["detail"] == "nessun frame: avviare l'anteprima o scattare"
|
||||||
|
|
||||||
|
|
||||||
|
def test_calibrata_senza_attiva_409(client):
|
||||||
|
_carica(client, DENSA, _densa())
|
||||||
|
assert _anteprima(client, DENSA).status_code == 200
|
||||||
|
r = _calibrata(client, DENSA)
|
||||||
|
assert r.status_code == 409 and r.json()["detail"] == "nessuna calibrazione attiva su questa sorgente"
|
||||||
|
|
||||||
|
|
||||||
|
def test_calibrata_validazione_ingresso(client):
|
||||||
|
assert client.post("/camera/anteprima/calibrata", json={}).status_code == 400
|
||||||
|
assert _calibrata(client, DENSA, lato_max=100).status_code == 400
|
||||||
|
assert _calibrata(client, DENSA, lato_max=True).status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_calibrata_200_densa_senza_nuova_lettura(client, monkeypatch):
|
||||||
|
modulo = _modulo(client)
|
||||||
|
reale = modulo["open_source"]
|
||||||
|
letture = []
|
||||||
|
|
||||||
|
def conta(*a, **k):
|
||||||
|
s = reale(*a, **k)
|
||||||
|
originale = s.grab
|
||||||
|
s.grab = lambda t=1000: (letture.append(1), originale(t))[1]
|
||||||
|
return s
|
||||||
|
|
||||||
|
monkeypatch.setitem(modulo, "open_source", conta)
|
||||||
|
_carica(client, DENSA, _densa())
|
||||||
|
_calibra_e_attiva(client, DENSA, "Banco àè 1")
|
||||||
|
assert _anteprima(client, DENSA).status_code == 200
|
||||||
|
prima = len(letture)
|
||||||
|
for _ in range(3):
|
||||||
|
r = _calibrata(client, DENSA)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.headers["content-type"] == "image/jpeg"
|
||||||
|
assert r.headers["X-Raddrizzato"] == "si"
|
||||||
|
assert (r.headers["X-Larghezza"], r.headers["X-Altezza"]) == ("640", "480")
|
||||||
|
assert unquote(r.headers["X-Calibrazione"]) == "Banco àè 1"
|
||||||
|
assert r.headers["X-Calibrazione"] == quote("Banco àè 1", safe="") # percent-encoded UTF-8
|
||||||
|
img = cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_UNCHANGED)
|
||||||
|
assert img.shape[:2] == (480, 640)
|
||||||
|
assert len(letture) == prima # la calibrata non legge mai un frame nuovo
|
||||||
|
|
||||||
|
|
||||||
|
def test_calibrata_ridotta_a_lato_max(client):
|
||||||
|
_carica(client, DENSA, _densa())
|
||||||
|
_calibra_e_attiva(client, DENSA, "Banco")
|
||||||
|
assert _anteprima(client, DENSA).status_code == 200
|
||||||
|
r = _calibrata(client, DENSA, lato_max=320)
|
||||||
|
assert r.status_code == 200
|
||||||
|
img = cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_UNCHANGED)
|
||||||
|
assert max(img.shape[:2]) == 320
|
||||||
|
assert (r.headers["X-Larghezza"], r.headers["X-Altezza"]) == ("640", "480") # misure del frame pieno
|
||||||
|
|
||||||
|
|
||||||
|
def test_anteprima_grezza_e_calibrata_usano_lo_stesso_frame_grezzo(client):
|
||||||
|
# anche con anteprima NON grezza la copia tenuta e' quella prima di raddrizzare
|
||||||
|
_carica(client, DENSA, _densa())
|
||||||
|
_calibra_e_attiva(client, DENSA, "Banco")
|
||||||
|
r = client.post("/camera/anteprima", json={"source": DENSA}) # calibrata: raddrizzata
|
||||||
|
assert r.status_code == 200 and r.headers["X-Raddrizzato"] == "si"
|
||||||
|
r = _calibrata(client, DENSA)
|
||||||
|
assert r.status_code == 200 and r.headers["X-Raddrizzato"] == "si" # raddrizzata una volta sola
|
||||||
|
|
||||||
|
|
||||||
|
def test_calibrata_risoluzione_diversa_409(client):
|
||||||
|
_carica(client, DENSA, _densa())
|
||||||
|
_calibra_e_attiva(client, DENSA, "Banco")
|
||||||
|
# stessa sorgente, immagine sostituita con un'altra risoluzione: la calibrazione resta attiva
|
||||||
|
assert _carica(client, DENSA, _densa(w=320, h=240)).status_code == 201
|
||||||
|
assert _calibrata(client, DENSA).status_code == 404 # sostituire l'immagine scorda il frame vecchio
|
||||||
|
assert _anteprima(client, DENSA).status_code == 200
|
||||||
|
r = _calibrata(client, DENSA)
|
||||||
|
assert r.status_code == 409
|
||||||
|
assert r.json()["detail"] == "la calibrazione «Banco» è per immagini 640×480, il frame è 320×240"
|
||||||
|
|
||||||
|
|
||||||
|
def test_calibrata_planare_senza_mappa_200_grezza(client):
|
||||||
|
# #95: senza mappa (planare) non e' piu' 409: esce il frame grezzo, X-Raddrizzato: no
|
||||||
|
_carica(client, PIANA, una_scacchiera())
|
||||||
|
_calibra_e_attiva(client, PIANA, "Piana", cols="9", rows="6")
|
||||||
|
assert _anteprima(client, PIANA).status_code == 200
|
||||||
|
r = _calibrata(client, PIANA)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.headers["X-Raddrizzato"] == "no" and unquote(r.headers["X-Calibrazione"]) == "Piana"
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""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",
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
"""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 "Attiva calibrazione" 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_non_sospeso_nella_tab_calibrazione():
|
||||||
|
# #94: il ciclo non si ferma piu' entrando in Calibrazione (prima #92: A.riprendi); Calcola senza blocco aspetta le richieste in volo
|
||||||
|
assert "A.riprendi" not in PAGINA
|
||||||
|
calcola = PAGINA.split("async function calcola")[1].split("async function salvaNome")[0]
|
||||||
|
assert "fermaAnteprima();" in calcola and "A.inVolo) await pausa(50)" 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
|
||||||
|
|
||||||
|
|
||||||
|
def test_elenco_sorgenti_senza_attese_e_calibrata_solo_se_serve():
|
||||||
|
# #92: la voce dell'immagine caricata si aggiunge in pagina (niente rilettura dell'elenco); «Aggiorna» forza la ricerca camere
|
||||||
|
assert "aggiungiSorgente(j);" in PAGINA and "caricaSorgenti(j.source)" not in PAGINA
|
||||||
|
assert 'id="aggiornaSorgenti"' in PAGINA and ">Aggiorna</button>" in PAGINA
|
||||||
|
assert '"?aggiorna=true"' in PAGINA
|
||||||
|
# nessuna calibrazione attiva: il riquadro lo dice senza chiamare il servizio; dopo un 409 non si richiede piu'
|
||||||
|
assert "if (A.calAttiva === false)" in PAGINA
|
||||||
|
assert "nessuna calibrazione attiva su questa sorgente" in PAGINA
|
||||||
|
assert "if (r.status === 409) A.bloccoCal = true;" in PAGINA and "if (A.bloccoCal) return;" in PAGINA
|
||||||
|
assert PAGINA.count("A.bloccoCal = false;") >= 3 # cambio sorgente, Carica, Scollega
|
||||||
|
|
||||||
|
|
||||||
|
def test_attiva_calibrazione_con_scelta_e_conferma():
|
||||||
|
# #93: sotto la calibrata una combo e CONFERMA; la casella cambia nome
|
||||||
|
for i in ('id="sceltaCal"', 'id="confermaCal"', 'id="msgSceltaCal"'):
|
||||||
|
assert i in PAGINA, i
|
||||||
|
assert "Attiva calibrazione" in PAGINA and ">CONFERMA</button>" in PAGINA
|
||||||
|
assert "Mostra immagine calibrata" not in PAGINA
|
||||||
|
assert PAGINA.index('id="riquadroCal"') < PAGINA.index('id="sceltaCal"') < PAGINA.index('id="confermaCal"') < PAGINA.index('id="msgSceltaCal"')
|
||||||
|
# il gestore di CONFERMA usa PUT calibrazione-attiva (via caricaNome) o DELETE calibration (via scollega), messaggio sotto la calibrata
|
||||||
|
gestore = PAGINA[PAGINA.index('$("confermaCal").onclick'):]
|
||||||
|
gestore = gestore[:gestore.index("};")]
|
||||||
|
assert 'caricaNome(nome, "msgSceltaCal")' in gestore and 'scollega("msgSceltaCal")' in gestore
|
||||||
|
assert 'method: "PUT"' in PAGINA and "/camera/calibrazione-attiva" in PAGINA
|
||||||
|
assert 'method: "DELETE"' in PAGINA and '"/camera/calibration?source="' in PAGINA
|
||||||
|
# voce vuota «nessuna», suffisso di risoluzione, combo ridisegnata con l'elenco e spenta senza sorgente
|
||||||
|
assert "— nessuna (non calibrata) —" in PAGINA and "— risoluzione diversa" in PAGINA
|
||||||
|
assert "disegnaSceltaCal();" in PAGINA and "= !$(\"sorgente\").value" in PAGINA
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""Test delle rotte /visore/*.js di camera_service e della pagina che le usa (#122).
|
||||||
|
|
||||||
|
Uso: python -m pytest scripts/tests/test_visore_rotte.py -q
|
||||||
|
Dipende da: venv con fastapi, httpx, opencv (come test_camera_service.py).
|
||||||
|
image_viewer.js lo scrive un altro piano (V1): se manca nei sorgenti il test della rotta usa una cartella temporanea con file finti.
|
||||||
|
"""
|
||||||
|
import importlib.util
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
MODULO = Path(__file__).resolve().parents[1] / "camera_service.py"
|
||||||
|
NOMI = ("image_viewer.js", "save_image.js", "vision_overlay.js")
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(tmp_path):
|
||||||
|
mod = _carica()
|
||||||
|
with TestClient(mod.create_app(tmp_path)) as c:
|
||||||
|
c.mod = mod
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def cartella_finta(client, tmp_path):
|
||||||
|
# file finti in una cartella temporanea: la rotta si prova senza dipendere da image_viewer.js del frontend
|
||||||
|
d = tmp_path / "js"
|
||||||
|
d.mkdir()
|
||||||
|
for n in NOMI:
|
||||||
|
(d / n).write_text("/* finto " + n + " */\n", encoding="utf-8")
|
||||||
|
client.mod._CARTELLE_VISORE[:] = [d]
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("nome", NOMI)
|
||||||
|
def test_rotta_serve_il_file(client, cartella_finta, nome):
|
||||||
|
r = client.get("/visore/" + nome)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.headers["content-type"].startswith("application/javascript")
|
||||||
|
assert r.content == (cartella_finta / nome).read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("nome", ["altro.js", "camera_service.py", "..%2Fcamera_service.py", "image_viewer.JS", "image_viewer.js.map"])
|
||||||
|
def test_altri_nomi_danno_404(client, cartella_finta, nome):
|
||||||
|
assert client.get("/visore/" + nome).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_mancante_nella_cartella_da_404(client, tmp_path):
|
||||||
|
client.mod._CARTELLE_VISORE[:] = [tmp_path / "vuota"]
|
||||||
|
assert client.get("/visore/save_image.js").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_cartelle_di_default_sono_static_js_del_frontend(client):
|
||||||
|
# sviluppo: sorgenti del frontend; pacchetto Nuitka: static/ accanto al modulo (build.ps1 la include)
|
||||||
|
cartelle = _carica()._CARTELLE_VISORE
|
||||||
|
assert any(c.parts[-5:] == ("src", "frontend", "flask_app", "static", "js") for c in cartelle)
|
||||||
|
assert any(c.parts[-2:] == ("static", "js") and c.parent.parent == MODULO.parent for c in cartelle)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("nome", ("save_image.js", "vision_overlay.js", "image_viewer.js"))
|
||||||
|
def test_file_reali_del_frontend(client, nome):
|
||||||
|
reale = next((c / nome for c in client.mod._CARTELLE_VISORE if (c / nome).is_file()), None)
|
||||||
|
if reale is None:
|
||||||
|
pytest.skip(nome + " non e' ancora nei sorgenti del frontend (lo scrive il piano V1)")
|
||||||
|
r = client.get("/visore/" + nome)
|
||||||
|
assert r.status_code == 200 and r.content == reale.read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("rotta", ("/acquisizione", "/calibrazione"))
|
||||||
|
def test_pagina_usa_quattro_visori_e_i_tre_script(client, rotta):
|
||||||
|
html = client.get(rotta).text
|
||||||
|
assert html.count("<tmf-viewer") == 4
|
||||||
|
src = re.findall(r'<script src="(/visore/[^"]+)"', html)
|
||||||
|
assert src == ["/visore/vision_overlay.js", "/visore/save_image.js", "/visore/image_viewer.js"]
|
||||||
|
assert "zoomAdatta" not in html
|
||||||
@@ -357,9 +357,13 @@ async def export_measurements_csv(
|
|||||||
# Rows
|
# Rows
|
||||||
for m in measurements:
|
for m in measurements:
|
||||||
# Format decimals with configured separator
|
# Format decimals with configured separator
|
||||||
value_str = str(m.value).replace(".", decimal_separator)
|
# #118: quota non misurata dalla visione -> NaN
|
||||||
|
value_str = "NaN" if m.value is None else str(m.value).replace(".", decimal_separator)
|
||||||
|
# #124: quota NaN (value None) -> deviazione NaN, non vuota
|
||||||
deviation_str = (
|
deviation_str = (
|
||||||
str(m.deviation).replace(".", decimal_separator)
|
"NaN"
|
||||||
|
if m.value is None
|
||||||
|
else str(m.deviation).replace(".", decimal_separator)
|
||||||
if m.deviation is not None
|
if m.deviation is not None
|
||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -67,6 +67,21 @@ async def create_new_station(
|
|||||||
# The int-typed station_id param already guards against "by-code" being
|
# The int-typed station_id param already guards against "by-code" being
|
||||||
# matched as a station id, but keeping the explicit order avoids surprises
|
# matched as a station id, but keeping the explicit order avoids surprises
|
||||||
# during refactors (e.g. if someone regroups handlers by HTTP method).
|
# during refactors (e.g. if someone regroups handlers by HTTP method).
|
||||||
|
@router.get("/by-code/{code}", response_model=StationResponse)
|
||||||
|
async def get_station_by_code(
|
||||||
|
code: str,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Get a station by its code (any authenticated user, like /by-code/{code}/recipes) (#72)."""
|
||||||
|
station = await station_service.get_station_by_code(db, code)
|
||||||
|
if station is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Station not found",
|
||||||
|
)
|
||||||
|
return StationResponse.model_validate(station)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/by-code/{code}/recipes", response_model=list[RecipeSummary])
|
@router.get("/by-code/{code}/recipes", response_model=list[RecipeSummary])
|
||||||
async def list_recipes_by_station_code(
|
async def list_recipes_by_station_code(
|
||||||
code: str,
|
code: str,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from src.backend.models.orm.measurement import Measurement
|
|||||||
from src.backend.models.orm.production import ProductionRun
|
from src.backend.models.orm.production import ProductionRun
|
||||||
from src.backend.models.orm.recipe import RecipeVersion
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||||
|
from src.backend.models.orm.task_result import TaskResult # #58: esiti dei task della sessione
|
||||||
from src.backend.models.orm.user import User
|
from src.backend.models.orm.user import User
|
||||||
from src.backend.models.api.statistics import (
|
from src.backend.models.api.statistics import (
|
||||||
CapabilityData,
|
CapabilityData,
|
||||||
@@ -145,7 +146,8 @@ async def get_capability(
|
|||||||
date_from, date_to, operator_id, lot_number, serial_number,
|
date_from, date_to, operator_id, lot_number, serial_number,
|
||||||
production_run_id=production_run_id, # #58
|
production_run_id=production_run_id, # #58
|
||||||
)
|
)
|
||||||
values = [float(m.value) for m in measurements]
|
# #118: le quote NaN (value None) non entrano nei calcoli
|
||||||
|
values = [float(m.value) for m in measurements if m.value is not None]
|
||||||
tol = await _get_subtask_tolerances(db, subtask_id)
|
tol = await _get_subtask_tolerances(db, subtask_id)
|
||||||
return compute_capability(values, tol["utl"], tol["ltl"], tol["nominal"])
|
return compute_capability(values, tol["utl"], tol["ltl"], tol["nominal"])
|
||||||
|
|
||||||
@@ -170,6 +172,8 @@ async def get_control_chart(
|
|||||||
date_from, date_to, operator_id, lot_number, serial_number,
|
date_from, date_to, operator_id, lot_number, serial_number,
|
||||||
production_run_id=production_run_id, # #58
|
production_run_id=production_run_id, # #58
|
||||||
)
|
)
|
||||||
|
# #118: le quote NaN (value None) si saltano, valori e istanti restano allineati
|
||||||
|
measurements = [m for m in measurements if m.value is not None]
|
||||||
values = [float(m.value) for m in measurements]
|
values = [float(m.value) for m in measurements]
|
||||||
timestamps = [m.measured_at for m in measurements]
|
timestamps = [m.measured_at for m in measurements]
|
||||||
tol = await _get_subtask_tolerances(db, subtask_id)
|
tol = await _get_subtask_tolerances(db, subtask_id)
|
||||||
@@ -200,7 +204,8 @@ async def get_histogram(
|
|||||||
date_from, date_to, operator_id, lot_number, serial_number,
|
date_from, date_to, operator_id, lot_number, serial_number,
|
||||||
production_run_id=production_run_id, # #58
|
production_run_id=production_run_id, # #58
|
||||||
)
|
)
|
||||||
values = [float(m.value) for m in measurements]
|
# #118: le quote NaN (value None) non entrano nell'istogramma
|
||||||
|
values = [float(m.value) for m in measurements if m.value is not None]
|
||||||
return compute_histogram(values, n_bins)
|
return compute_histogram(values, n_bins)
|
||||||
|
|
||||||
|
|
||||||
@@ -294,3 +299,76 @@ async def get_recipe_runs(
|
|||||||
}
|
}
|
||||||
for r in runs
|
for r in runs
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/runs/{run_id}/task-results")
|
||||||
|
async def get_run_task_results(
|
||||||
|
run_id: int,
|
||||||
|
user: User = Depends(require_metrologist),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""#58: esiti, note, campi e allegati dei task di una sessione di produzione.
|
||||||
|
|
||||||
|
Endpoint di statistica (require_metrologist): GET /api/task-results vuole
|
||||||
|
il ruolo MeasurementTec e non si puo' riusare qui.
|
||||||
|
"""
|
||||||
|
run = (await db.execute(
|
||||||
|
select(ProductionRun.id).where(ProductionRun.id == run_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if run is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Sessione non trovata")
|
||||||
|
|
||||||
|
# #58: una query per i risultati della run, una per i task, una per gli utenti.
|
||||||
|
results = (await db.execute(
|
||||||
|
select(TaskResult)
|
||||||
|
.where(TaskResult.production_run_id == run_id)
|
||||||
|
.order_by(TaskResult.created_at.asc(), TaskResult.id.asc())
|
||||||
|
)).scalars().all()
|
||||||
|
|
||||||
|
task_ids = {r.task_id for r in results}
|
||||||
|
tasks = {}
|
||||||
|
if task_ids:
|
||||||
|
tasks = {t.id: t for t in (await db.execute(
|
||||||
|
select(RecipeTask).where(RecipeTask.id.in_(task_ids))
|
||||||
|
)).scalars().all()}
|
||||||
|
user_ids = {r.user_id for r in results}
|
||||||
|
names = {}
|
||||||
|
if user_ids:
|
||||||
|
names = dict((await db.execute(
|
||||||
|
select(User.id, User.display_name).where(User.id.in_(user_ids))
|
||||||
|
)).all())
|
||||||
|
|
||||||
|
counts = {"ok": 0, "nok": 0, "done": 0}
|
||||||
|
out = []
|
||||||
|
for r in results:
|
||||||
|
counts[r.outcome] = counts.get(r.outcome, 0) + 1
|
||||||
|
task = tasks.get(r.task_id)
|
||||||
|
# #58: etichette dai campi del task; se il campo e' stato tolto vale la chiave.
|
||||||
|
labels = {
|
||||||
|
f.get("key"): f.get("label") or f.get("key")
|
||||||
|
for f in (task.fields or []) if isinstance(f, dict)
|
||||||
|
} if task else {}
|
||||||
|
out.append({
|
||||||
|
"id": r.id,
|
||||||
|
"task_id": r.task_id,
|
||||||
|
"task_title": task.title if task else None,
|
||||||
|
"task_type": task.task_type if task else None,
|
||||||
|
"outcome": r.outcome,
|
||||||
|
"note": r.note,
|
||||||
|
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||||
|
"user_name": names.get(r.user_id),
|
||||||
|
"values": [
|
||||||
|
{"key": k, "label": labels.get(k, k), "value": v}
|
||||||
|
for k, v in (r.values or {}).items()
|
||||||
|
],
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"key": a.get("key"),
|
||||||
|
"label": labels.get(a.get("key"), a.get("key")),
|
||||||
|
"name": a.get("name"),
|
||||||
|
"path": a.get("path"),
|
||||||
|
}
|
||||||
|
for a in (r.attachments or []) if isinstance(a, dict)
|
||||||
|
],
|
||||||
|
})
|
||||||
|
return {"run_id": run_id, "counts": counts, "results": out}
|
||||||
|
|||||||
@@ -250,6 +250,8 @@ async def create_task(
|
|||||||
file_type=data.file_type,
|
file_type=data.file_type,
|
||||||
annotations_json=data.annotations_json,
|
annotations_json=data.annotations_json,
|
||||||
vision_json=data.vision_json,
|
vision_json=data.vision_json,
|
||||||
|
# #97: camera e modo di analisi (dict gia' validato dal modello)
|
||||||
|
acquisition_json=data.acquisition_json.model_dump() if data.acquisition_json else None,
|
||||||
# #53 #55: ciclo (default dal tipo), chiusura, campi extra
|
# #53 #55: ciclo (default dal tipo), chiusura, campi extra
|
||||||
in_loop=data.in_loop if data.in_loop is not None else default_in_loop(data.task_type),
|
in_loop=data.in_loop if data.in_loop is not None else default_in_loop(data.task_type),
|
||||||
at_close=data.at_close,
|
at_close=data.at_close,
|
||||||
|
|||||||
@@ -4,8 +4,12 @@ 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
|
||||||
|
import mimetypes
|
||||||
|
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
|
||||||
@@ -20,13 +24,13 @@ from src.backend.api.routers.files import (
|
|||||||
from src.backend.config import settings
|
from src.backend.config import settings
|
||||||
from src.backend.database import get_db
|
from src.backend.database import get_db
|
||||||
from src.backend.models.api.vision import (
|
from src.backend.models.api.vision import (
|
||||||
VisionExecuteResponse, VisionMeasurementResult, VisionRecipeResponse,
|
VisionCheckResponse, VisionExecuteResponse, VisionMeasurementResult, VisionRecipeResponse,
|
||||||
VisionRecipeUploadResponse, VisionReferenceImageResponse,
|
VisionRecipeUploadResponse, VisionReferenceImageResponse,
|
||||||
)
|
)
|
||||||
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"])
|
||||||
|
|
||||||
@@ -37,14 +41,22 @@ _IMAGE_EXTENSIONS = {
|
|||||||
"image/png": "png",
|
"image/png": "png",
|
||||||
"image/gif": "gif",
|
"image/gif": "gif",
|
||||||
"image/webp": "webp",
|
"image/webp": "webp",
|
||||||
|
"image/bmp": "bmp", # #99
|
||||||
|
"image/x-ms-bmp": "bmp", # #99
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# #99: the Opto bench cameras save BMP and the worker decodes it (PIL), so the
|
||||||
|
# vision routes accept it on top of files.py's list. files.py is untouched: its
|
||||||
|
# attachments are served by extension and BMP is not meant to be shown there.
|
||||||
|
_VISION_IMAGE_TYPES = ALLOWED_IMAGE_TYPES | {"image/bmp", "image/x-ms-bmp"}
|
||||||
|
|
||||||
# Sniffed from the first bytes, independent of the declared Content-Type: a
|
# Sniffed from the first bytes, independent of the declared Content-Type: a
|
||||||
# text file uploaded as "image/png" passes the header check above and must
|
# text file uploaded as "image/png" passes the header check above and must
|
||||||
# still be caught before it reaches the worker as a bare 500 (#15). BMP and
|
# still be caught before it reaches the worker as a bare 500 (#15). TIFF is
|
||||||
# TIFF are included even though `_IMAGE_EXTENSIONS` does not accept them,
|
# included even though the allow-list does not accept it (PIL, and so the
|
||||||
# because PIL - and so the worker - can decode both; this check only refuses
|
# worker, can decode it; browsers do not show it in the result); BMP is now
|
||||||
# what is not a real image at all, it does not narrow the allow-list above.
|
# also allowed (#99). This check only refuses what is not a real image at all,
|
||||||
|
# it does not narrow the allow-list above.
|
||||||
_IMAGE_SIGNATURES: tuple[tuple[bytes, ...], ...] = (
|
_IMAGE_SIGNATURES: tuple[tuple[bytes, ...], ...] = (
|
||||||
(b"\x89PNG\r\n\x1a\n",), # PNG
|
(b"\x89PNG\r\n\x1a\n",), # PNG
|
||||||
(b"\xff\xd8\xff",), # JPEG
|
(b"\xff\xd8\xff",), # JPEG
|
||||||
@@ -80,7 +92,7 @@ async def _read_image(image: UploadFile) -> bytes:
|
|||||||
is in hand - the same two-step convention `files.py::upload_file` already
|
is in hand - the same two-step convention `files.py::upload_file` already
|
||||||
uses, reused rather than reinvented here.
|
uses, reused rather than reinvented here.
|
||||||
"""
|
"""
|
||||||
if image.content_type not in ALLOWED_IMAGE_TYPES:
|
if image.content_type not in _VISION_IMAGE_TYPES: # #99
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=f"File type {image.content_type} not allowed. Must be an image.",
|
detail=f"File type {image.content_type} not allowed. Must be an image.",
|
||||||
@@ -150,7 +162,10 @@ async def execute(
|
|||||||
engine_version=result.engine_version,
|
engine_version=result.engine_version,
|
||||||
measurements=[
|
measurements=[
|
||||||
VisionMeasurementResult(
|
VisionMeasurementResult(
|
||||||
subtask_id=m.subtask_id, value=float(m.value), pass_fail=m.pass_fail,
|
subtask_id=m.subtask_id,
|
||||||
|
# #118: None = quota non prodotta, il frontend la mostra NaN
|
||||||
|
value=float(m.value) if m.value is not None else None,
|
||||||
|
pass_fail=m.pass_fail,
|
||||||
)
|
)
|
||||||
for m in saved
|
for m in saved
|
||||||
],
|
],
|
||||||
@@ -161,6 +176,22 @@ async def execute(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/check", response_model=VisionCheckResponse)
|
||||||
|
async def check(
|
||||||
|
task_id: int = Form(...),
|
||||||
|
image: UploadFile = File(...),
|
||||||
|
production_run_id: int | None = Form(None, gt=0),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_measurement_tec),
|
||||||
|
) -> VisionCheckResponse:
|
||||||
|
"""#97: analisi di un frame senza salvare nulla (ne' risultato ne' misure)."""
|
||||||
|
task = await _task_or_404(db, task_id)
|
||||||
|
report = await vision_service.check_task(
|
||||||
|
db, task, await _read_image(image), production_run_id=production_run_id,
|
||||||
|
)
|
||||||
|
return VisionCheckResponse(**report)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/results/{result_id}/image")
|
@router.get("/results/{result_id}/image")
|
||||||
async def get_result_image(
|
async def get_result_image(
|
||||||
result_id: int,
|
result_id: int,
|
||||||
@@ -185,6 +216,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 +273,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 +368,133 @@ 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("/dxf-preview")
|
||||||
|
async def dxf_preview(
|
||||||
|
dxf: UploadFile = File(...),
|
||||||
|
current_user: User = Depends(require_maker),
|
||||||
|
) -> dict:
|
||||||
|
"""#101: anteprima del DXF scelto nel wizard «Crea ricetta da DXF» (contorni
|
||||||
|
in mm e dimensioni), letto dal worker con lo stesso lettore del confronto.
|
||||||
|
Non scrive nulla; stesso limite di dimensione della rotta dxf-compare."""
|
||||||
|
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"])
|
||||||
|
return await vision_service.dxf_preview(dxf_bytes)
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
|
# #105: l'immagine di riferimento resta nella cartella (non e' in meta, quindi
|
||||||
|
# /validate e l'esecuzione la ignorano) e il wizard puo' riaprirla; entra nel digest.
|
||||||
|
placed[f"riferimento.{_IMAGE_EXTENSIONS.get(image.content_type, 'png')}"] = image_bytes
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tasks/{task_id}/recipe/dxf-compare/source")
|
||||||
|
async def get_dxf_compare_source(
|
||||||
|
task_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_maker),
|
||||||
|
) -> dict:
|
||||||
|
"""#105: i parametri della ricetta fatta con «Crea ricetta da DXF», per modificarla.
|
||||||
|
|
||||||
|
404 se il task non ha una ricetta del wizard (nessuna, o caricata a mano).
|
||||||
|
"""
|
||||||
|
task = await _task_or_404(db, task_id)
|
||||||
|
source = vision_service.dxf_compare_source(task.vision_json, task.vision_recipe_dir)
|
||||||
|
if source is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="nessuna ricetta da DXF su questo task",
|
||||||
|
)
|
||||||
|
return source
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tasks/{task_id}/recipe/files/{path:path}")
|
||||||
|
async def get_recipe_file(
|
||||||
|
task_id: int,
|
||||||
|
path: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_maker),
|
||||||
|
) -> FileResponse:
|
||||||
|
"""#105: un file della cartella ricetta del task (DXF, immagine di riferimento).
|
||||||
|
|
||||||
|
Percorso non sicuro, file assente o task senza ricetta: sempre 404.
|
||||||
|
"""
|
||||||
|
task = await _task_or_404(db, task_id)
|
||||||
|
target = vision_service.recipe_file_path(task.vision_recipe_dir, path)
|
||||||
|
if target is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="file not found")
|
||||||
|
media_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream"
|
||||||
|
return FileResponse(target, media_type=media_type)
|
||||||
|
|||||||
+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(
|
||||||
|
|||||||
@@ -22,24 +22,44 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
op.create_table(
|
# Idempotent on purpose (#71): a database born from create_all has no
|
||||||
'api_keys',
|
# `uq_users_api_key` (only the unique index `ix_users_api_key`), and on
|
||||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
# MySQL a run that dies half way (no transactional DDL) leaves `api_keys`
|
||||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
# created and `alembic_version` still at 014. Every step checks first.
|
||||||
sa.Column('key_hash', sa.String(64), nullable=False),
|
insp = sa.inspect(op.get_bind())
|
||||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
|
||||||
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
|
||||||
sa.Column('last_used_at', sa.DateTime(), nullable=True),
|
|
||||||
sa.ForeignKeyConstraint(
|
|
||||||
['user_id'], ['users.id'], name='fk_api_keys_user_id', ondelete='CASCADE'
|
|
||||||
),
|
|
||||||
)
|
|
||||||
op.create_index('ix_api_keys_key_hash', 'api_keys', ['key_hash'], unique=True)
|
|
||||||
op.create_index('ix_api_keys_user_id', 'api_keys', ['user_id'])
|
|
||||||
|
|
||||||
|
if 'api_keys' not in insp.get_table_names():
|
||||||
|
op.create_table(
|
||||||
|
'api_keys',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('key_hash', sa.String(64), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('last_used_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
['user_id'], ['users.id'], name='fk_api_keys_user_id', ondelete='CASCADE'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
existing_idx: set[str] = set()
|
||||||
|
else:
|
||||||
|
existing_idx = {i['name'] for i in insp.get_indexes('api_keys')}
|
||||||
|
|
||||||
|
if 'ix_api_keys_key_hash' not in existing_idx:
|
||||||
|
op.create_index('ix_api_keys_key_hash', 'api_keys', ['key_hash'], unique=True)
|
||||||
|
if 'ix_api_keys_user_id' not in existing_idx:
|
||||||
|
op.create_index('ix_api_keys_user_id', 'api_keys', ['user_id'])
|
||||||
|
|
||||||
|
if 'api_key' not in {c['name'] for c in insp.get_columns('users')}:
|
||||||
|
return # column already gone: nothing else to do
|
||||||
|
|
||||||
|
user_idx = {i['name'] for i in insp.get_indexes('users')}
|
||||||
|
user_uq = {u['name'] for u in insp.get_unique_constraints('users')}
|
||||||
with op.batch_alter_table('users') as batch:
|
with op.batch_alter_table('users') as batch:
|
||||||
batch.drop_index('ix_users_api_key')
|
if 'ix_users_api_key' in user_idx:
|
||||||
batch.drop_constraint('uq_users_api_key', type_='unique')
|
batch.drop_index('ix_users_api_key')
|
||||||
|
if 'uq_users_api_key' in user_uq:
|
||||||
|
batch.drop_constraint('uq_users_api_key', type_='unique')
|
||||||
batch.drop_column('api_key')
|
batch.drop_column('api_key')
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""stations.camera_url e stations.camera_source (#72)
|
||||||
|
|
||||||
|
stations.camera_url: servizio camera della stazione (vs-gui o equivalente).
|
||||||
|
stations.camera_source: sorgente da chiedere al servizio, es. camera:<seriale>
|
||||||
|
o camera:folder.
|
||||||
|
|
||||||
|
Revision ID: 020_station_camera
|
||||||
|
Revises: 019_station_vision_worker
|
||||||
|
Create Date: 2026-10-15
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '020_station_camera'
|
||||||
|
down_revision: Union[str, None] = '019_station_vision_worker'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# batch_alter_table come nelle migrazioni vicine: serve a SQLite
|
||||||
|
with op.batch_alter_table('stations') as batch:
|
||||||
|
batch.add_column(sa.Column('camera_url', sa.String(500), nullable=True))
|
||||||
|
batch.add_column(sa.Column('camera_source', sa.String(200), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('stations') as batch:
|
||||||
|
batch.drop_column('camera_source')
|
||||||
|
batch.drop_column('camera_url')
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""recipe_tasks.acquisition_json (#97)
|
||||||
|
|
||||||
|
recipe_tasks.acquisition_json: camera e modo di analisi scelti nel task,
|
||||||
|
{"camera_url", "camera_source", "modo": "impulsiva" | "in_linea"}; NULL = la
|
||||||
|
camera della stazione dell'operatore, analisi impulsiva (come prima).
|
||||||
|
|
||||||
|
Revision ID: 021_task_acquisition
|
||||||
|
Revises: 020_station_camera
|
||||||
|
Create Date: 2026-10-24
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '021_task_acquisition'
|
||||||
|
down_revision: Union[str, None] = '020_station_camera'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# batch_alter_table come nelle migrazioni vicine: serve a SQLite
|
||||||
|
with op.batch_alter_table('recipe_tasks') as batch:
|
||||||
|
batch.add_column(sa.Column('acquisition_json', sa.JSON(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('recipe_tasks') as batch:
|
||||||
|
batch.drop_column('acquisition_json')
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""measurements.value nullable (#118)
|
||||||
|
|
||||||
|
measurements.value: NULL = quota che il grafo di visione non ha prodotto
|
||||||
|
(l'operatore la vede come NaN, esito sempre `fail`, deviazione NULL). Prima
|
||||||
|
execute rifiutava con 422 l'intera acquisizione. measurements.deviation era
|
||||||
|
gia' nullable.
|
||||||
|
|
||||||
|
Downgrade: torna NOT NULL; FALLISCE se esistono righe con value NULL (vanno
|
||||||
|
cancellate o valorizzate prima).
|
||||||
|
|
||||||
|
Revision ID: 022_measurement_value_nullable
|
||||||
|
Revises: 021_task_acquisition
|
||||||
|
Create Date: 2026-10-28
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '022_measurement_value_nullable'
|
||||||
|
down_revision: Union[str, None] = '021_task_acquisition'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# batch_alter_table come nelle migrazioni vicine: serve a SQLite
|
||||||
|
with op.batch_alter_table('measurements') as batch:
|
||||||
|
batch.alter_column(
|
||||||
|
'value', existing_type=sa.DECIMAL(12, 6), nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('measurements') as batch:
|
||||||
|
batch.alter_column(
|
||||||
|
'value', existing_type=sa.DECIMAL(12, 6), nullable=False,
|
||||||
|
)
|
||||||
@@ -43,7 +43,7 @@ class MeasurementResponse(BaseModel):
|
|||||||
subtask_id: int
|
subtask_id: int
|
||||||
version_id: int
|
version_id: int
|
||||||
measured_by: int
|
measured_by: int
|
||||||
value: float
|
value: Optional[float] = None # #118: null = quota non prodotta dalla visione (NaN)
|
||||||
pass_fail: str
|
pass_fail: str
|
||||||
deviation: Optional[float] = None
|
deviation: Optional[float] = None
|
||||||
lot_number: Optional[str] = None
|
lot_number: Optional[str] = None
|
||||||
@@ -108,7 +108,7 @@ class CycleMeasurementResponse(BaseModel):
|
|||||||
"""One quote already measured in the cycle under way."""
|
"""One quote already measured in the cycle under way."""
|
||||||
|
|
||||||
subtask_id: int
|
subtask_id: int
|
||||||
value: float
|
value: Optional[float] = None # #118: null = NaN
|
||||||
pass_fail: str
|
pass_fail: str
|
||||||
measured_at: UtcDateTime
|
measured_at: UtcDateTime
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class MonitorSubtask(BaseModel):
|
|||||||
description: str
|
description: str
|
||||||
unit: str
|
unit: str
|
||||||
count: int
|
count: int
|
||||||
mean: float
|
mean: Optional[float] = None # #118: None = solo quote NaN
|
||||||
ltl: Optional[float] = None
|
ltl: Optional[float] = None
|
||||||
utl: Optional[float] = None
|
utl: Optional[float] = None
|
||||||
# True when ltl <= mean <= utl (a missing limit does not constrain).
|
# True when ltl <= mean <= utl (a missing limit does not constrain).
|
||||||
|
|||||||
@@ -5,18 +5,35 @@ from typing import Optional
|
|||||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
def _normalize_worker_url(value: Optional[str]) -> Optional[str]:
|
def _normalize_http_url(value: Optional[str], field: str) -> Optional[str]:
|
||||||
"""Vuoto/spazi -> None (worker centrale); altrimenti http(s) senza '/' finale (#45)."""
|
"""Vuoto/spazi -> None; altrimenti http(s) senza '/' finale."""
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
value = value.strip()
|
value = value.strip()
|
||||||
if not value:
|
if not value:
|
||||||
return None
|
return None
|
||||||
if not value.lower().startswith(("http://", "https://")):
|
if not value.lower().startswith(("http://", "https://")):
|
||||||
raise ValueError("vision_worker_url must start with http:// or https://")
|
raise ValueError(f"{field} must start with http:// or https://")
|
||||||
return value.rstrip("/")
|
return value.rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_worker_url(value: Optional[str]) -> Optional[str]:
|
||||||
|
"""Vuoto/spazi -> None (worker centrale); altrimenti http(s) senza '/' finale (#45)."""
|
||||||
|
return _normalize_http_url(value, "vision_worker_url")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_camera_url(value: Optional[str]) -> Optional[str]:
|
||||||
|
"""Vuoto/spazi -> None (nessuna camera); altrimenti http(s) senza '/' finale (#72)."""
|
||||||
|
return _normalize_http_url(value, "camera_url")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_camera_source(value: Optional[str]) -> Optional[str]:
|
||||||
|
"""Strip; vuoto -> None (#72)."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return value.strip() or None
|
||||||
|
|
||||||
|
|
||||||
class StationCreate(BaseModel):
|
class StationCreate(BaseModel):
|
||||||
code: str = Field(..., min_length=1, max_length=100)
|
code: str = Field(..., min_length=1, max_length=100)
|
||||||
name: str = Field(..., min_length=1, max_length=255)
|
name: str = Field(..., min_length=1, max_length=255)
|
||||||
@@ -25,8 +42,13 @@ class StationCreate(BaseModel):
|
|||||||
active: bool = True
|
active: bool = True
|
||||||
# worker di visione della stazione (#45); vuoto = centrale
|
# worker di visione della stazione (#45); vuoto = centrale
|
||||||
vision_worker_url: Optional[str] = Field(default=None, max_length=500)
|
vision_worker_url: Optional[str] = Field(default=None, max_length=500)
|
||||||
|
# camera della stazione (#72); vuoto = nessuna camera
|
||||||
|
camera_url: Optional[str] = Field(default=None, max_length=500)
|
||||||
|
camera_source: Optional[str] = Field(default=None, max_length=200)
|
||||||
|
|
||||||
_check_worker_url = field_validator("vision_worker_url")(_normalize_worker_url)
|
_check_worker_url = field_validator("vision_worker_url")(_normalize_worker_url)
|
||||||
|
_check_camera_url = field_validator("camera_url")(_normalize_camera_url)
|
||||||
|
_check_camera_source = field_validator("camera_source")(_normalize_camera_source)
|
||||||
|
|
||||||
|
|
||||||
class StationUpdate(BaseModel):
|
class StationUpdate(BaseModel):
|
||||||
@@ -36,8 +58,13 @@ class StationUpdate(BaseModel):
|
|||||||
active: Optional[bool] = None
|
active: Optional[bool] = None
|
||||||
# worker di visione della stazione (#45); "" -> None riporta al centrale
|
# worker di visione della stazione (#45); "" -> None riporta al centrale
|
||||||
vision_worker_url: Optional[str] = Field(default=None, max_length=500)
|
vision_worker_url: Optional[str] = Field(default=None, max_length=500)
|
||||||
|
# camera della stazione (#72); vuoto = nessuna camera
|
||||||
|
camera_url: Optional[str] = Field(default=None, max_length=500)
|
||||||
|
camera_source: Optional[str] = Field(default=None, max_length=200)
|
||||||
|
|
||||||
_check_worker_url = field_validator("vision_worker_url")(_normalize_worker_url)
|
_check_worker_url = field_validator("vision_worker_url")(_normalize_worker_url)
|
||||||
|
_check_camera_url = field_validator("camera_url")(_normalize_camera_url)
|
||||||
|
_check_camera_source = field_validator("camera_source")(_normalize_camera_source)
|
||||||
|
|
||||||
|
|
||||||
class StationResponse(BaseModel):
|
class StationResponse(BaseModel):
|
||||||
@@ -49,6 +76,8 @@ class StationResponse(BaseModel):
|
|||||||
notes: Optional[str]
|
notes: Optional[str]
|
||||||
active: bool
|
active: bool
|
||||||
vision_worker_url: Optional[str] = None # #45
|
vision_worker_url: Optional[str] = None # #45
|
||||||
|
camera_url: Optional[str] = None # #72
|
||||||
|
camera_source: Optional[str] = None # #72
|
||||||
created_by: int
|
created_by: int
|
||||||
created_at: UtcDateTime
|
created_at: UtcDateTime
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
import re
|
import re
|
||||||
from typing import Any, Literal, Optional
|
from typing import Any, Literal, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||||
|
|
||||||
|
from src.backend.models.api.station import _normalize_http_url
|
||||||
from src.backend.models.orm.task import DEFAULT_LOOP_TASK_TYPES, TASK_TYPES
|
from src.backend.models.orm.task import DEFAULT_LOOP_TASK_TYPES, TASK_TYPES
|
||||||
|
|
||||||
# Kept in step with the ORM enum so the two cannot drift apart.
|
# Kept in step with the ORM enum so the two cannot drift apart.
|
||||||
@@ -159,6 +160,30 @@ class SubtaskResponse(BaseModel):
|
|||||||
vision_output: Optional[str] = None
|
vision_output: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AcquisitionConfig(BaseModel):
|
||||||
|
"""#97: camera (servizio + sorgente) e modo di analisi scelti nel task."""
|
||||||
|
camera_url: str = Field(..., max_length=500)
|
||||||
|
camera_source: str = Field(..., min_length=1, max_length=200)
|
||||||
|
modo: Literal["impulsiva", "in_linea"] = "impulsiva"
|
||||||
|
|
||||||
|
@field_validator("camera_url")
|
||||||
|
@classmethod
|
||||||
|
def _check_camera_url(cls, value: str) -> str:
|
||||||
|
# stessa normalizzazione delle stazioni; qui il vuoto non e' ammesso
|
||||||
|
normalized = _normalize_http_url(value, "camera_url")
|
||||||
|
if normalized is None:
|
||||||
|
raise ValueError("camera_url must not be empty")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
@field_validator("camera_source")
|
||||||
|
@classmethod
|
||||||
|
def _check_camera_source(cls, value: str) -> str:
|
||||||
|
value = value.strip()
|
||||||
|
if not value:
|
||||||
|
raise ValueError("camera_source must not be empty")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class TaskCreate(BaseModel):
|
class TaskCreate(BaseModel):
|
||||||
"""Schema for creating a task."""
|
"""Schema for creating a task."""
|
||||||
title: str = Field(..., min_length=1, max_length=255)
|
title: str = Field(..., min_length=1, max_length=255)
|
||||||
@@ -169,6 +194,7 @@ class TaskCreate(BaseModel):
|
|||||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||||
annotations_json: Optional[dict[str, Any]] = None
|
annotations_json: Optional[dict[str, Any]] = None
|
||||||
vision_json: Optional[dict] = None
|
vision_json: Optional[dict] = None
|
||||||
|
acquisition_json: Optional[AcquisitionConfig] = None # #97
|
||||||
# #53: assente = default dal tipo (misure e check nel ciclo)
|
# #53: assente = default dal tipo (misure e check nel ciclo)
|
||||||
in_loop: Optional[bool] = None
|
in_loop: Optional[bool] = None
|
||||||
at_close: bool = False
|
at_close: bool = False
|
||||||
@@ -186,6 +212,7 @@ class TaskUpdate(BaseModel):
|
|||||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||||
annotations_json: Optional[dict[str, Any]] = None
|
annotations_json: Optional[dict[str, Any]] = None
|
||||||
vision_json: Optional[dict] = None
|
vision_json: Optional[dict] = None
|
||||||
|
acquisition_json: Optional[AcquisitionConfig] = None # #97
|
||||||
in_loop: Optional[bool] = None
|
in_loop: Optional[bool] = None
|
||||||
at_close: Optional[bool] = None
|
at_close: Optional[bool] = None
|
||||||
fields: Optional[list[dict[str, Any]]] = None
|
fields: Optional[list[dict[str, Any]]] = None
|
||||||
@@ -207,6 +234,7 @@ class TaskResponse(BaseModel):
|
|||||||
file_type: Optional[str] = None
|
file_type: Optional[str] = None
|
||||||
annotations_json: Optional[dict[str, Any]] = None
|
annotations_json: Optional[dict[str, Any]] = None
|
||||||
vision_json: Optional[dict] = None
|
vision_json: Optional[dict] = None
|
||||||
|
acquisition_json: Optional[AcquisitionConfig] = None # #97
|
||||||
in_loop: bool = False
|
in_loop: bool = False
|
||||||
at_close: bool = False
|
at_close: bool = False
|
||||||
fields: Optional[list[dict[str, Any]]] = None
|
fields: Optional[list[dict[str, Any]]] = None
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from pydantic import BaseModel, ConfigDict
|
|||||||
class VisionMeasurementResult(BaseModel):
|
class VisionMeasurementResult(BaseModel):
|
||||||
"""One quote's outcome, as much of it as the client needs to show."""
|
"""One quote's outcome, as much of it as the client needs to show."""
|
||||||
subtask_id: int
|
subtask_id: int
|
||||||
value: float
|
value: float | None # #118: null = quota non prodotta dal grafo (NaN), pass_fail "fail"
|
||||||
pass_fail: str
|
pass_fail: str
|
||||||
|
|
||||||
|
|
||||||
@@ -24,6 +24,16 @@ class VisionExecuteResponse(BaseModel):
|
|||||||
failures: list[dict] = []
|
failures: list[dict] = []
|
||||||
|
|
||||||
|
|
||||||
|
class VisionCheckResponse(BaseModel):
|
||||||
|
"""What `POST /api/vision/check` returns (#97): the analysis, nothing saved."""
|
||||||
|
outcome: str | None = None
|
||||||
|
overlay: list[dict] = []
|
||||||
|
failures: list[dict] = []
|
||||||
|
measurements: list[VisionMeasurementResult] = []
|
||||||
|
# esito della lettura della calibrazione: ok | no_camera | not_calibrated | unreachable
|
||||||
|
calibration: str
|
||||||
|
|
||||||
|
|
||||||
class VisionRecipeOutput(BaseModel):
|
class VisionRecipeOutput(BaseModel):
|
||||||
"""One numeric output a recipe offers to the quotes."""
|
"""One numeric output a recipe offers to the quotes."""
|
||||||
key: str
|
key: str
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ class Measurement(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Measurement data
|
# Measurement data
|
||||||
value: Mapped[float] = mapped_column(DECIMAL(12, 6), nullable=False)
|
# #118: NULL = quota che il grafo di visione non ha prodotto (mostrata NaN, sempre fail)
|
||||||
|
value: Mapped[Optional[float]] = mapped_column(DECIMAL(12, 6), nullable=True)
|
||||||
pass_fail: Mapped[str] = mapped_column(
|
pass_fail: Mapped[str] = mapped_column(
|
||||||
Enum("pass", "warning", "fail", name="pass_fail_enum"), nullable=False
|
Enum("pass", "warning", "fail", name="pass_fail_enum"), nullable=False
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ class Station(Base):
|
|||||||
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||||
# URL del worker di visione della stazione (#45); NULL = worker centrale
|
# URL del worker di visione della stazione (#45); NULL = worker centrale
|
||||||
vision_worker_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
vision_worker_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
# camera della stazione (#72): URL del servizio camera e sorgente da chiedergli
|
||||||
|
camera_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
camera_source: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
|
||||||
created_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
|
created_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime, nullable=False, server_default=func.now()
|
DateTime, nullable=False, server_default=func.now()
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ class RecipeTask(Base):
|
|||||||
# Cartella immutabile (relativa a upload_path) con recipe.json e i file di
|
# Cartella immutabile (relativa a upload_path) con recipe.json e i file di
|
||||||
# meta della ricetta VisionSuite v2 (#46).
|
# meta della ricetta VisionSuite v2 (#46).
|
||||||
vision_recipe_dir: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
vision_recipe_dir: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
# #97: camera e modo di analisi del task, {camera_url, camera_source, modo};
|
||||||
|
# NULL = camera della stazione corrente, analisi impulsiva.
|
||||||
|
acquisition_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||||
# #53: il task fa parte del ciclo di produzione (default dal tipo: misure e
|
# #53: il task fa parte del ciclo di produzione (default dal tipo: misure e
|
||||||
# check si', gli altri no; un task 'note' si puo' mettere nel ciclo a mano).
|
# check si', gli altri no; un task 'note' si puo' mettere nel ciclo a mano).
|
||||||
in_loop: Mapped[bool] = mapped_column(
|
in_loop: Mapped[bool] = mapped_column(
|
||||||
|
|||||||
@@ -302,7 +302,7 @@ async def current_cycle_measurements(
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"subtask_id": m.subtask_id,
|
"subtask_id": m.subtask_id,
|
||||||
"value": float(m.value),
|
"value": float(m.value) if m.value is not None else None, # #118: NaN
|
||||||
"pass_fail": m.pass_fail,
|
"pass_fail": m.pass_fail,
|
||||||
"measured_at": m.measured_at,
|
"measured_at": m.measured_at,
|
||||||
}
|
}
|
||||||
@@ -355,7 +355,7 @@ async def save_measurement(
|
|||||||
subtask_id: int,
|
subtask_id: int,
|
||||||
version_id: int,
|
version_id: int,
|
||||||
measured_by: int,
|
measured_by: int,
|
||||||
value: float,
|
value: float | None,
|
||||||
lot_number: str | None = None,
|
lot_number: str | None = None,
|
||||||
serial_number: str | None = None,
|
serial_number: str | None = None,
|
||||||
input_method: str = "manual",
|
input_method: str = "manual",
|
||||||
@@ -372,8 +372,12 @@ async def save_measurement(
|
|||||||
batch of quotes taken at the same instant (see Ruling R10 / vision_service):
|
batch of quotes taken at the same instant (see Ruling R10 / vision_service):
|
||||||
the gate models "moving on" between measurements taken one after another,
|
the gate models "moving on" between measurements taken one after another,
|
||||||
which does not exist inside a single camera acquisition.
|
which does not exist inside a single camera acquisition.
|
||||||
|
|
||||||
|
#118: ``value=None`` is a quote the vision graph did not produce (shown as
|
||||||
|
NaN): saved as a ``fail`` with no deviation. Only the vision path passes it.
|
||||||
"""
|
"""
|
||||||
_validate_measured_value(value)
|
if value is not None: # #118: una quota non misurata non ha un valore da validare
|
||||||
|
_validate_measured_value(value)
|
||||||
|
|
||||||
# Get subtask for tolerance values
|
# Get subtask for tolerance values
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
@@ -421,8 +425,12 @@ async def save_measurement(
|
|||||||
# Rounded once, here, so the stored value is exactly the one the verdict
|
# Rounded once, here, so the stored value is exactly the one the verdict
|
||||||
# above was computed on - calculate_pass_fail rounds internally too, but
|
# above was computed on - calculate_pass_fail rounds internally too, but
|
||||||
# matching it here keeps the persisted row from disagreeing with itself.
|
# matching it here keeps the persisted row from disagreeing with itself.
|
||||||
value = round(value, 6)
|
if value is None:
|
||||||
pass_fail, deviation = calculate_pass_fail(value, subtask)
|
# #118: quota non prodotta dal grafo -> NaN: fail, nessuna deviazione
|
||||||
|
pass_fail, deviation = "fail", None
|
||||||
|
else:
|
||||||
|
value = round(value, 6)
|
||||||
|
pass_fail, deviation = calculate_pass_fail(value, subtask)
|
||||||
|
|
||||||
measurement = Measurement(
|
measurement = Measurement(
|
||||||
subtask_id=subtask_id,
|
subtask_id=subtask_id,
|
||||||
|
|||||||
@@ -96,14 +96,21 @@ async def list_open_runs(db: AsyncSession) -> list[MonitorRun]:
|
|||||||
per_sub[row.subtask_id].append(row)
|
per_sub[row.subtask_id].append(row)
|
||||||
subtasks = []
|
subtasks = []
|
||||||
for sid, group in sorted(per_sub.items()):
|
for sid, group in sorted(per_sub.items()):
|
||||||
mean = sum(float(g.value) for g in group) / len(group)
|
# #118: le quote NaN (value None) non entrano nella media; con soli
|
||||||
|
# NaN la quota resta in elenco con mean None ed e' fuori tolleranza
|
||||||
|
numeric = [float(g.value) for g in group if g.value is not None]
|
||||||
|
mean = sum(numeric) / len(numeric) if numeric else None
|
||||||
first = group[0]
|
first = group[0]
|
||||||
subtasks.append(MonitorSubtask(
|
subtasks.append(MonitorSubtask(
|
||||||
subtask_id=sid, description=first.description, unit=first.unit,
|
subtask_id=sid, description=first.description, unit=first.unit,
|
||||||
count=len(group), mean=round(mean, 6),
|
count=len(group),
|
||||||
|
mean=round(mean, 6) if mean is not None else None, # #118
|
||||||
ltl=float(first.ltl) if first.ltl is not None else None,
|
ltl=float(first.ltl) if first.ltl is not None else None,
|
||||||
utl=float(first.utl) if first.utl is not None else None,
|
utl=float(first.utl) if first.utl is not None else None,
|
||||||
in_tolerance=_in_tolerance(mean, first.ltl, first.utl),
|
in_tolerance=(
|
||||||
|
_in_tolerance(mean, first.ltl, first.utl)
|
||||||
|
if mean is not None else False # #118: tutte NaN = fail
|
||||||
|
),
|
||||||
))
|
))
|
||||||
timer = production_service.describe(run)
|
timer = production_service.describe(run)
|
||||||
ev = last_event.get(run.id)
|
ev = last_event.get(run.id)
|
||||||
|
|||||||
@@ -159,8 +159,8 @@ async def build_statistics_csv(db: AsyncSession, run: ProductionRun) -> str:
|
|||||||
subtask.marker_number if subtask else "",
|
subtask.marker_number if subtask else "",
|
||||||
neutralise_csv_cell(subtask.description) if subtask else "",
|
neutralise_csv_cell(subtask.description) if subtask else "",
|
||||||
number(subtask.nominal) if subtask else "",
|
number(subtask.nominal) if subtask else "",
|
||||||
number(measurement.value),
|
number(measurement.value) if measurement.value is not None else "NaN", # #118
|
||||||
number(measurement.deviation),
|
"NaN" if measurement.value is None else number(measurement.deviation), # #124: quota NaN -> deviazione NaN
|
||||||
measurement.pass_fail,
|
measurement.pass_fail,
|
||||||
number(subtask.ltl) if subtask else "",
|
number(subtask.ltl) if subtask else "",
|
||||||
number(subtask.lwl) if subtask else "",
|
number(subtask.lwl) if subtask else "",
|
||||||
|
|||||||
@@ -117,6 +117,8 @@ async def _copy_tasks_to_version(
|
|||||||
vision_json=task.vision_json,
|
vision_json=task.vision_json,
|
||||||
# Cartella immutabile: basta il riferimento (#46).
|
# Cartella immutabile: basta il riferimento (#46).
|
||||||
vision_recipe_dir=task.vision_recipe_dir,
|
vision_recipe_dir=task.vision_recipe_dir,
|
||||||
|
# #97: camera e modo di analisi seguono il task nella nuova versione.
|
||||||
|
acquisition_json=task.acquisition_json,
|
||||||
# #53 #55: ciclo, chiusura e campi extra seguono il task nella nuova versione.
|
# #53 #55: ciclo, chiusura e campi extra seguono il task nella nuova versione.
|
||||||
in_loop=task.in_loop,
|
in_loop=task.in_loop,
|
||||||
at_close=task.at_close,
|
at_close=task.at_close,
|
||||||
|
|||||||
@@ -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(
|
||||||
@@ -300,8 +318,10 @@ async def generate_spc_report(
|
|||||||
|
|
||||||
# 2. Compute SPC data
|
# 2. Compute SPC data
|
||||||
pass_fail_values = [m.pass_fail for m in measurements]
|
pass_fail_values = [m.pass_fail for m in measurements]
|
||||||
values = [float(m.value) for m in measurements]
|
# #118: le quote NaN (value None) contano nell'esito ma non nei calcoli
|
||||||
timestamps = [m.measured_at for m in measurements]
|
numeric = [m for m in measurements if m.value is not None]
|
||||||
|
values = [float(m.value) for m in numeric]
|
||||||
|
timestamps = [m.measured_at for m in numeric]
|
||||||
|
|
||||||
summary = compute_summary(pass_fail_values)
|
summary = compute_summary(pass_fail_values)
|
||||||
|
|
||||||
@@ -408,7 +428,7 @@ async def generate_measurement_report(
|
|||||||
rows.append({
|
rows.append({
|
||||||
"num": i,
|
"num": i,
|
||||||
"subtask": f"#{st_info.get('marker_number', '?')} — {st_info.get('description', '?')}",
|
"subtask": f"#{st_info.get('marker_number', '?')} — {st_info.get('description', '?')}",
|
||||||
"value": f"{float(m.value):.4f}",
|
"value": f"{float(m.value):.4f}" if m.value is not None else "NaN", # #118
|
||||||
"unit": st_info.get("unit", "mm"),
|
"unit": st_info.get("unit", "mm"),
|
||||||
"pass_fail": m.pass_fail,
|
"pass_fail": m.pass_fail,
|
||||||
"measured_at": to_local(m.measured_at).strftime("%Y-%m-%d %H:%M"),
|
"measured_at": to_local(m.measured_at).strftime("%Y-%m-%d %H:%M"),
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ async def create_station(
|
|||||||
notes=data.notes,
|
notes=data.notes,
|
||||||
active=data.active,
|
active=data.active,
|
||||||
vision_worker_url=data.vision_worker_url, # #45
|
vision_worker_url=data.vision_worker_url, # #45
|
||||||
|
camera_url=data.camera_url, # #72
|
||||||
|
camera_source=data.camera_source, # #72
|
||||||
created_by=creator.id,
|
created_by=creator.id,
|
||||||
)
|
)
|
||||||
db.add(station)
|
db.add(station)
|
||||||
|
|||||||
@@ -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,183 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
async def dxf_preview(dxf_bytes: bytes, worker_url: str | None = None) -> dict:
|
||||||
|
"""#101: anteprima del DXF (contorni in mm + dimensioni) dal worker. Sostituita dai test."""
|
||||||
|
body = await call_worker_multipart(
|
||||||
|
"/dxf/preview",
|
||||||
|
files={"dxf": ("disegno.dxf", dxf_bytes, "application/octet-stream")},
|
||||||
|
data={},
|
||||||
|
worker_url=worker_url,
|
||||||
|
)
|
||||||
|
# forma minima del contratto: senza contorni e dimensioni non c'e' nulla da mostrare
|
||||||
|
required = {"contorni", "bbox", "larghezza_mm", "altezza_mm", "entita", "punti"}
|
||||||
|
if not required <= body.keys():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail="the vision worker's answer was malformed: missing "
|
||||||
|
+ ", ".join(sorted(required - body.keys())),
|
||||||
|
)
|
||||||
|
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_calibration(
|
||||||
|
camera_url: str | None, camera_source: str | None, label: str = "",
|
||||||
|
) -> tuple[dict | None, str]:
|
||||||
|
"""Legge la calibrazione da un acquisitore (C1, #83; generalizzata in #97).
|
||||||
|
|
||||||
|
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. `label` serve
|
||||||
|
solo ai log (stazione o task).
|
||||||
|
"""
|
||||||
|
if not camera_url or not camera_source:
|
||||||
|
return None, CAL_NO_CAMERA
|
||||||
|
url = f"{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": camera_source})
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
logger.warning("acquisitore %s non raggiungibile: %s", label, 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 %s: HTTP %s", label, response.status_code)
|
||||||
|
return None, CAL_UNREACHABLE
|
||||||
|
try:
|
||||||
|
body = response.json()
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("acquisitore %s: risposta non JSON", label)
|
||||||
|
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 fetch_station_calibration(station: Station | None) -> tuple[dict | None, str]:
|
||||||
|
"""Calibrazione dall'acquisitore della stazione (C1, #83): vedi fetch_calibration."""
|
||||||
|
if station is None:
|
||||||
|
return None, CAL_NO_CAMERA
|
||||||
|
return await fetch_calibration(
|
||||||
|
station.camera_url, station.camera_source, f"della stazione {station.code}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def task_camera(task: RecipeTask, station: Station | None) -> tuple[str | None, str | None]:
|
||||||
|
"""Camera di un task (#97): acquisition_json, altrimenti quella della stazione."""
|
||||||
|
acquisition = task.acquisition_json
|
||||||
|
if isinstance(acquisition, dict) and acquisition.get("camera_url") and acquisition.get("camera_source"):
|
||||||
|
return acquisition["camera_url"], acquisition["camera_source"]
|
||||||
|
if station is not None:
|
||||||
|
return station.camera_url, station.camera_source
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_task_calibration(
|
||||||
|
task: RecipeTask, station: Station | None,
|
||||||
|
) -> tuple[dict | None, str]:
|
||||||
|
"""Calibrazione della camera del task (#97), stessi esiti CAL_* di fetch_calibration."""
|
||||||
|
camera_url, camera_source = task_camera(task, station)
|
||||||
|
return await fetch_calibration(camera_url, camera_source, f"del task {task.id}")
|
||||||
|
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
@@ -305,6 +485,84 @@ def list_recipe_files(recipe_dir: str | None) -> list[str]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# #105: nomi e id della ricetta costruita da «Crea ricetta da DXF» (src/vision/costruttori.py).
|
||||||
|
_WIZARD_DXF_FILE = "disegno.dxf"
|
||||||
|
_WIZARD_CALIBRATION_FILE = "calibrazione.json"
|
||||||
|
_WIZARD_REFERENCE_STEM = "riferimento"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_real_number(value: object) -> bool:
|
||||||
|
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
|
||||||
|
|
||||||
|
|
||||||
|
def dxf_compare_source(graph: dict | None, recipe_dir: str | None) -> dict | None:
|
||||||
|
"""#105: i parametri con cui il wizard ha costruito la ricetta, per riaprirlo.
|
||||||
|
|
||||||
|
Legge i valori dal grafo (tool `pezzo` LocateBlob, tool `dxf` CompareDxf) e i
|
||||||
|
file dalla cartella ricetta. None se il grafo non e' uno del wizard (il task
|
||||||
|
non ha ricetta, o l'ha caricata a mano): la rotta risponde 404. Pura: niente DB.
|
||||||
|
"""
|
||||||
|
if not isinstance(graph, dict) or not isinstance(graph.get("tools"), list):
|
||||||
|
return None
|
||||||
|
tools = {t.get("id"): t for t in graph["tools"] if isinstance(t, dict)}
|
||||||
|
pezzo, confronto = tools.get("pezzo"), tools.get("dxf")
|
||||||
|
if not pezzo or pezzo.get("kind") != "LocateBlob":
|
||||||
|
return None
|
||||||
|
if not confronto or confronto.get("kind") != "CompareDxf":
|
||||||
|
return None
|
||||||
|
p_values = pezzo.get("values") if isinstance(pezzo.get("values"), dict) else {}
|
||||||
|
c_values = confronto.get("values") if isinstance(confronto.get("values"), dict) else {}
|
||||||
|
band, min_pct = c_values.get("band_plus"), c_values.get("points_in_pct_nominal")
|
||||||
|
if not (_is_real_number(band) and _is_real_number(min_pct)):
|
||||||
|
return None
|
||||||
|
polarity = p_values.get("polarity")
|
||||||
|
if polarity not in ("dark", "light"):
|
||||||
|
polarity = "dark" # default di LocateBlob e della rotta di creazione
|
||||||
|
|
||||||
|
folder = None
|
||||||
|
if recipe_dir:
|
||||||
|
try:
|
||||||
|
folder = _safe_join(settings.upload_path, recipe_dir)
|
||||||
|
except (ValueError, OSError):
|
||||||
|
folder = None
|
||||||
|
if folder is not None and not folder.is_dir():
|
||||||
|
folder = None
|
||||||
|
|
||||||
|
px_per_mm = None
|
||||||
|
dxf_name = None
|
||||||
|
image_name = None
|
||||||
|
if folder is not None:
|
||||||
|
try:
|
||||||
|
cal = json.loads((folder / _WIZARD_CALIBRATION_FILE).read_text(encoding="utf-8"))
|
||||||
|
value = cal.get("px_per_mm") if isinstance(cal, dict) else None
|
||||||
|
if _is_real_number(value) and value > 0:
|
||||||
|
px_per_mm = float(value)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
px_per_mm = None # manca o illeggibile: il wizard chiedera' la scala
|
||||||
|
if (folder / _WIZARD_DXF_FILE).is_file():
|
||||||
|
dxf_name = _WIZARD_DXF_FILE
|
||||||
|
for candidate in sorted(folder.glob(f"{_WIZARD_REFERENCE_STEM}.*")):
|
||||||
|
if candidate.is_file():
|
||||||
|
image_name = candidate.name
|
||||||
|
break
|
||||||
|
return {
|
||||||
|
"band_mm": float(band), "min_points_pct": float(min_pct), "polarity": polarity,
|
||||||
|
"px_per_mm": px_per_mm, "dxf": dxf_name, "image": image_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def recipe_file_path(recipe_dir: str | None, relative: str) -> Path | None:
|
||||||
|
"""#105: percorso di un file della cartella ricetta, o None se non sicuro o assente."""
|
||||||
|
if not recipe_dir:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
folder = _safe_join(settings.upload_path, recipe_dir)
|
||||||
|
target = _safe_join(folder, relative)
|
||||||
|
except (ValueError, OSError):
|
||||||
|
return None
|
||||||
|
return target if target.is_file() else None
|
||||||
|
|
||||||
|
|
||||||
def save_result_files(
|
def save_result_files(
|
||||||
task_id: int, image_bytes: bytes, overlay: list, image_ext: str = "png",
|
task_id: int, image_bytes: bytes, overlay: list, image_ext: str = "png",
|
||||||
) -> tuple[str, str]:
|
) -> tuple[str, str]:
|
||||||
@@ -365,11 +623,15 @@ 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 (#83); None = non c'e'. #97: la camera e'
|
||||||
|
# quella del task se la ricetta ne sceglie una, altrimenti quella della stazione.
|
||||||
|
station = 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 fetch_task_calibration(task, station)
|
||||||
|
|
||||||
if not task.vision_json:
|
if not task.vision_json:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -419,31 +681,25 @@ 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"]
|
||||||
|
|
||||||
# Every quote or none. A half-measured piece read as a whole one is worse
|
# #118: una quota che il grafo non produce non blocca piu' l'esecuzione
|
||||||
# than a piece not measured at all. A quote counts as produced only if its
|
# (prima 422 "the graph did not produce"): viene salvata con value NULL,
|
||||||
# output is present *and* a finite, non-bool number: the in-process runner
|
# mostrata NaN, sempre `fail`; i motivi restano nei `failures` del risultato.
|
||||||
# already drops NaN/inf itself (see src/vision/runner.py), but the worker
|
# Una quota conta come prodotta solo se l'uscita c'e' *ed* e' un numero
|
||||||
# is reached over HTTP - a different implementation of it, or a malformed
|
# finito non-bool: il worker risponde via HTTP, un'altra implementazione o
|
||||||
# response, could still hand back None or a non-finite float for a mapped
|
# una risposta malformata potrebbe dare None o un non finito per una chiave
|
||||||
# key instead of omitting it outright.
|
# mappata invece di ometterla.
|
||||||
missing = [q.vision_output for q in quotes if not _is_measurable(outputs.get(q.vision_output))]
|
|
||||||
if missing:
|
|
||||||
failed = ", ".join(f["tool_name"] for f in report["failures"])
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
||||||
detail=(
|
|
||||||
f"the graph did not produce {', '.join(missing)}"
|
|
||||||
+ (f" (tools that failed: {failed})" if failed else "")
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
overlay = report.get("overlay") or []
|
overlay = report.get("overlay") or []
|
||||||
image_path, overlay_path = save_result_files(task.id, image_bytes, overlay, image_ext)
|
image_path, overlay_path = save_result_files(task.id, image_bytes, overlay, image_ext)
|
||||||
@@ -457,6 +713,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)
|
||||||
@@ -469,7 +727,11 @@ async def execute_task(
|
|||||||
subtask_id=quote.id,
|
subtask_id=quote.id,
|
||||||
version_id=version_id,
|
version_id=version_id,
|
||||||
measured_by=measured_by,
|
measured_by=measured_by,
|
||||||
value=float(outputs[quote.vision_output]),
|
# #118: None = quota non prodotta (NaN), salvata come fail
|
||||||
|
value=(
|
||||||
|
float(outputs[quote.vision_output])
|
||||||
|
if _is_measurable(outputs.get(quote.vision_output)) else None
|
||||||
|
),
|
||||||
lot_number=lot_number,
|
lot_number=lot_number,
|
||||||
serial_number=serial_number,
|
serial_number=serial_number,
|
||||||
input_method="camera",
|
input_method="camera",
|
||||||
@@ -481,6 +743,89 @@ async def execute_task(
|
|||||||
return result, saved, report
|
return result, saved, report
|
||||||
|
|
||||||
|
|
||||||
|
async def check_task(
|
||||||
|
db: AsyncSession,
|
||||||
|
task: RecipeTask,
|
||||||
|
image_bytes: bytes,
|
||||||
|
*,
|
||||||
|
production_run_id: int | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Esegue il grafo come execute_task ma NON salva nulla (#97).
|
||||||
|
|
||||||
|
Anteprima per l'operatore (ingresso nel task, ciclo «in linea»): niente
|
||||||
|
VisionResult, niente file, niente misure. L'esito per quota usa
|
||||||
|
calculate_pass_fail sulle quote con vision_output, nell'ordine di
|
||||||
|
marker_number come execute. Le quote che il grafo non produce (pezzo non
|
||||||
|
ancora in posizione) entrano in `measurements` con `value` None (NaN) e
|
||||||
|
`pass_fail` "fail" (#118, come execute); il perche' sta in `failures`:
|
||||||
|
un'anteprima non deve dare 422 a ogni frame.
|
||||||
|
"""
|
||||||
|
if task.task_type not in VISION_TASK_TYPES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=f"task type {task.task_type!r} is not a camera measurement",
|
||||||
|
)
|
||||||
|
station = None
|
||||||
|
station_url = None
|
||||||
|
if production_run_id is not None:
|
||||||
|
# stesse regole di execute: 404 / 422 / 409 sulla sessione di produzione
|
||||||
|
run = await measurement_service.validate_run_for_version(
|
||||||
|
db, production_run_id, task.version_id,
|
||||||
|
)
|
||||||
|
station = (await db.execute(
|
||||||
|
select(Station).where(Station.id == run.station_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
station_url = (station.vision_worker_url or None) if station is not None else None
|
||||||
|
if not task.vision_json:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail="this task carries no vision graph",
|
||||||
|
)
|
||||||
|
quotes = (await db.execute(
|
||||||
|
select(RecipeSubtask)
|
||||||
|
.where(RecipeSubtask.task_id == task.id)
|
||||||
|
.where(RecipeSubtask.vision_output.is_not(None))
|
||||||
|
.order_by(RecipeSubtask.marker_number)
|
||||||
|
)).scalars().all()
|
||||||
|
if not quotes:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail="no quote on this task names a vision output",
|
||||||
|
)
|
||||||
|
|
||||||
|
# stessa calibrazione di execute: camera del task, altrimenti della stazione
|
||||||
|
calibration, cal_reason = await fetch_task_calibration(task, station)
|
||||||
|
worker_kwargs: dict = {}
|
||||||
|
if calibration is not None:
|
||||||
|
worker_kwargs["calibration"] = {"px_per_mm": float(calibration["px_per_mm"])}
|
||||||
|
report = await _call_worker(
|
||||||
|
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
|
||||||
|
worker_url=station_url,
|
||||||
|
**worker_kwargs,
|
||||||
|
)
|
||||||
|
outputs = report["outputs"]
|
||||||
|
# #118: una voce per ogni quota, in ordine di marker_number; non prodotta -> NaN/fail
|
||||||
|
measurements = [
|
||||||
|
{
|
||||||
|
"subtask_id": q.id,
|
||||||
|
"value": float(outputs[q.vision_output]),
|
||||||
|
"pass_fail": measurement_service.calculate_pass_fail(
|
||||||
|
float(outputs[q.vision_output]), q,
|
||||||
|
)[0],
|
||||||
|
} if _is_measurable(outputs.get(q.vision_output)) else {
|
||||||
|
"subtask_id": q.id, "value": None, "pass_fail": "fail",
|
||||||
|
}
|
||||||
|
for q in quotes
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"outcome": report.get("outcome"),
|
||||||
|
"overlay": report.get("overlay") or [],
|
||||||
|
"failures": report.get("failures") or [],
|
||||||
|
"measurements": measurements,
|
||||||
|
"calibration": cal_reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _reference_file_path(reference: VisionReferenceImage) -> Path:
|
def _reference_file_path(reference: VisionReferenceImage) -> Path:
|
||||||
return settings.upload_path / reference.path
|
return settings.upload_path / reference.path
|
||||||
|
|
||||||
@@ -601,3 +946,62 @@ 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.
|
||||||
|
#105: unica eccezione, la quota nata da qui (`dxf.points_in_pct`, «Punti in
|
||||||
|
tolleranza») senza misure segue la soglia nuova: ltl = min_points_pct, e
|
||||||
|
ritorna True anche in quel caso.
|
||||||
|
"""
|
||||||
|
from src.backend.services import recipe_service # import locale: evita cicli
|
||||||
|
|
||||||
|
# #105: ricetta ripresa e salvata con un'altra soglia -> la quota la segue
|
||||||
|
own = (await db.execute(
|
||||||
|
select(RecipeSubtask)
|
||||||
|
.where(RecipeSubtask.task_id == task.id)
|
||||||
|
.where(RecipeSubtask.vision_output == "dxf.points_in_pct")
|
||||||
|
.where(RecipeSubtask.description == "Punti in tolleranza")
|
||||||
|
.limit(1)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if own is not None:
|
||||||
|
# soglia invariata o versione con misure: la quota resta com'e'
|
||||||
|
same = own.ltl is not None and float(own.ltl) == float(min_points_pct)
|
||||||
|
if same or await recipe_service.version_has_measurements(db, task.version_id):
|
||||||
|
return False
|
||||||
|
own.ltl = min_points_pct
|
||||||
|
await db.flush()
|
||||||
|
return True
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from alembic import command
|
from alembic import command
|
||||||
|
from alembic.operations import Operations
|
||||||
|
from alembic.runtime.migration import MigrationContext
|
||||||
from alembic.config import Config
|
from alembic.config import Config
|
||||||
|
import sqlalchemy as sa
|
||||||
from sqlalchemy import create_engine, inspect as sa_inspect, text
|
from sqlalchemy import create_engine, inspect as sa_inspect, text
|
||||||
|
|
||||||
from src.backend.config import Settings
|
from src.backend.config import Settings
|
||||||
@@ -132,6 +135,70 @@ class TestApiKeyHashMigration:
|
|||||||
command.upgrade(cfg, "head") # and up again
|
command.upgrade(cfg, "head") # and up again
|
||||||
assert "api_keys" in _inspect(db_path).get_table_names()
|
assert "api_keys" in _inspect(db_path).get_table_names()
|
||||||
|
|
||||||
|
def test_015_on_create_all_schema(self, sqlite_database_url):
|
||||||
|
"""DB nato da create_all: users.api_key ha solo l'indice unique
|
||||||
|
ix_users_api_key, nessun vincolo uq_users_api_key (#71)."""
|
||||||
|
db_path, _ = sqlite_database_url
|
||||||
|
cfg = _alembic_config()
|
||||||
|
command.upgrade(cfg, "014_vision_fk_ondelete")
|
||||||
|
|
||||||
|
engine = create_engine(f"sqlite:///{db_path}")
|
||||||
|
with engine.begin() as conn:
|
||||||
|
ctx = MigrationContext.configure(conn)
|
||||||
|
ops = Operations(ctx)
|
||||||
|
with ops.batch_alter_table("users") as batch:
|
||||||
|
batch.drop_index("ix_users_api_key")
|
||||||
|
batch.drop_constraint("uq_users_api_key", type_="unique")
|
||||||
|
ops.create_index("ix_users_api_key", "users", ["api_key"], unique=True)
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
insp = _inspect(db_path)
|
||||||
|
assert not insp.get_unique_constraints("users") or "uq_users_api_key" not in {
|
||||||
|
u["name"] for u in insp.get_unique_constraints("users")
|
||||||
|
}
|
||||||
|
|
||||||
|
command.upgrade(cfg, "head") # non deve sollevare
|
||||||
|
|
||||||
|
insp = _inspect(db_path)
|
||||||
|
assert "api_keys" in insp.get_table_names()
|
||||||
|
assert "api_key" not in {c["name"] for c in insp.get_columns("users")}
|
||||||
|
|
||||||
|
def test_015_resumes_after_partial_run(self, sqlite_database_url):
|
||||||
|
"""015 a meta' (MySQL, DDL non transazionale): api_keys creata e
|
||||||
|
ix_users_api_key tolto, alembic_version ancora 014 (#71)."""
|
||||||
|
db_path, _ = sqlite_database_url
|
||||||
|
cfg = _alembic_config()
|
||||||
|
command.upgrade(cfg, "014_vision_fk_ondelete")
|
||||||
|
|
||||||
|
engine = create_engine(f"sqlite:///{db_path}")
|
||||||
|
with engine.begin() as conn:
|
||||||
|
ops = Operations(MigrationContext.configure(conn))
|
||||||
|
ops.create_table(
|
||||||
|
"api_keys",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("key_hash", sa.String(64), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("expires_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("last_used_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["user_id"], ["users.id"],
|
||||||
|
name="fk_api_keys_user_id", ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
ops.create_index("ix_api_keys_key_hash", "api_keys", ["key_hash"], unique=True)
|
||||||
|
ops.drop_index("ix_users_api_key", table_name="users")
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
command.upgrade(cfg, "head") # non deve sollevare
|
||||||
|
|
||||||
|
insp = _inspect(db_path)
|
||||||
|
assert "api_keys" in insp.get_table_names()
|
||||||
|
assert {i["name"] for i in insp.get_indexes("api_keys")} >= {
|
||||||
|
"ix_api_keys_key_hash", "ix_api_keys_user_id",
|
||||||
|
}
|
||||||
|
assert "api_key" not in {c["name"] for c in insp.get_columns("users")}
|
||||||
|
|
||||||
|
|
||||||
class TestVisionRecipeDirMigration:
|
class TestVisionRecipeDirMigration:
|
||||||
"""016_vision_recipe_dir: recipe_tasks.vision_recipe_dir, su e giu (#46)."""
|
"""016_vision_recipe_dir: recipe_tasks.vision_recipe_dir, su e giu (#46)."""
|
||||||
@@ -254,3 +321,113 @@ class TestStationVisionWorkerMigration:
|
|||||||
command.upgrade(cfg, "head") # e di nuovo su
|
command.upgrade(cfg, "head") # e di nuovo su
|
||||||
assert "vision_worker_url" in cols("stations")
|
assert "vision_worker_url" in cols("stations")
|
||||||
assert "worker_url" in cols("vision_results")
|
assert "worker_url" in cols("vision_results")
|
||||||
|
|
||||||
|
|
||||||
|
class TestStationCameraMigration:
|
||||||
|
"""020_station_camera: stations.camera_url e stations.camera_source, su e giu (#72)."""
|
||||||
|
|
||||||
|
def test_020_upgrade_and_downgrade(self, sqlite_database_url):
|
||||||
|
db_path, _ = sqlite_database_url
|
||||||
|
cfg = _alembic_config()
|
||||||
|
|
||||||
|
def cols(table):
|
||||||
|
return {c["name"] for c in _inspect(db_path).get_columns(table)}
|
||||||
|
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
assert {"camera_url", "camera_source"} <= cols("stations")
|
||||||
|
command.downgrade(cfg, "019_station_vision_worker")
|
||||||
|
assert "camera_url" not in cols("stations")
|
||||||
|
assert "camera_source" not in cols("stations")
|
||||||
|
assert "vision_worker_url" in cols("stations") # la 019 resta
|
||||||
|
command.upgrade(cfg, "head") # e di nuovo su
|
||||||
|
assert {"camera_url", "camera_source"} <= cols("stations")
|
||||||
|
|
||||||
|
|
||||||
|
class TestTaskAcquisitionMigration:
|
||||||
|
"""021_task_acquisition: recipe_tasks.acquisition_json, su e giu (#97)."""
|
||||||
|
|
||||||
|
def test_021_upgrade_and_downgrade(self, sqlite_database_url):
|
||||||
|
db_path, _ = sqlite_database_url
|
||||||
|
cfg = _alembic_config()
|
||||||
|
|
||||||
|
def cols(table):
|
||||||
|
return {c["name"] for c in _inspect(db_path).get_columns(table)}
|
||||||
|
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
assert "acquisition_json" in cols("recipe_tasks")
|
||||||
|
command.downgrade(cfg, "020_station_camera")
|
||||||
|
assert "acquisition_json" not in cols("recipe_tasks")
|
||||||
|
assert "camera_url" in cols("stations") # la 020 resta
|
||||||
|
command.upgrade(cfg, "head") # e di nuovo su
|
||||||
|
assert "acquisition_json" in cols("recipe_tasks")
|
||||||
|
|
||||||
|
|
||||||
|
class TestMeasurementValueNullableMigration:
|
||||||
|
"""022_measurement_value_nullable: measurements.value accetta NULL, su e giu (#118)."""
|
||||||
|
|
||||||
|
def test_022_upgrade_and_downgrade(self, sqlite_database_url):
|
||||||
|
db_path, _ = sqlite_database_url
|
||||||
|
cfg = _alembic_config()
|
||||||
|
|
||||||
|
def value_nullable():
|
||||||
|
cols = _inspect(db_path).get_columns("measurements")
|
||||||
|
return {c["name"]: c["nullable"] for c in cols}["value"]
|
||||||
|
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
assert value_nullable() is True
|
||||||
|
command.downgrade(cfg, "021_task_acquisition")
|
||||||
|
assert value_nullable() is False
|
||||||
|
command.upgrade(cfg, "head") # e di nuovo su
|
||||||
|
assert value_nullable() is True
|
||||||
|
|
||||||
|
def test_022_keeps_rows(self, sqlite_database_url):
|
||||||
|
"""#125: la ricostruzione della tabella conserva righe, somma dei value e indici."""
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
db_path, _ = sqlite_database_url
|
||||||
|
cfg = _alembic_config()
|
||||||
|
command.upgrade(cfg, "021_task_acquisition")
|
||||||
|
|
||||||
|
def indexes(con):
|
||||||
|
return sorted(r[0] for r in con.execute(
|
||||||
|
"select name from sqlite_master where type='index' and tbl_name='measurements'"
|
||||||
|
))
|
||||||
|
|
||||||
|
con = sqlite3.connect(db_path)
|
||||||
|
# Colonne NOT NULL senza default riempite per tipo (stessa idea di mig_check.py)
|
||||||
|
vals = {}
|
||||||
|
for _cid, name, typ, notnull, dflt, pk in con.execute("pragma table_info(measurements)").fetchall():
|
||||||
|
if pk or not notnull or dflt is not None:
|
||||||
|
continue
|
||||||
|
t = typ.upper()
|
||||||
|
vals[name] = (
|
||||||
|
1 if "INT" in t
|
||||||
|
else 1.5 if ("DEC" in t or "FLOAT" in t or "NUM" in t)
|
||||||
|
else "2026-01-01 00:00:00" if ("DATE" in t or "TIME" in t)
|
||||||
|
else "x"
|
||||||
|
)
|
||||||
|
vals["pass_fail"] = "pass"
|
||||||
|
vals["value"] = 12.5
|
||||||
|
sql = f"insert into measurements ({','.join(vals)}) values ({','.join('?' * len(vals))})"
|
||||||
|
for _ in range(3):
|
||||||
|
con.execute(sql, list(vals.values()))
|
||||||
|
con.commit()
|
||||||
|
before_idx = indexes(con)
|
||||||
|
before = con.execute("select count(*), sum(value) from measurements").fetchone()
|
||||||
|
con.close()
|
||||||
|
assert before == (3, 37.5)
|
||||||
|
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
|
||||||
|
con = sqlite3.connect(db_path)
|
||||||
|
try:
|
||||||
|
assert con.execute("select count(*), sum(value) from measurements").fetchone() == before
|
||||||
|
assert indexes(con) == before_idx
|
||||||
|
assert con.execute("pragma integrity_check").fetchone()[0] == "ok"
|
||||||
|
vals["value"] = None
|
||||||
|
vals["pass_fail"] = "fail"
|
||||||
|
con.execute(sql, list(vals.values())) # una riga NaN (value NULL) ora e' accettata
|
||||||
|
con.commit()
|
||||||
|
assert con.execute("select count(*) from measurements where value is null").fetchone()[0] == 1
|
||||||
|
finally:
|
||||||
|
con.close()
|
||||||
|
|||||||
@@ -81,3 +81,29 @@ async def test_open_lists_only_open_runs_with_means(client, db_session, admin_us
|
|||||||
async def test_open_empty_for_admin(client, admin_user):
|
async def test_open_empty_for_admin(client, admin_user):
|
||||||
resp = await client.get("/api/production-runs/open", headers=auth_headers(admin_user))
|
resp = await client.get("/api/production-runs/open", headers=auth_headers(admin_user))
|
||||||
assert resp.status_code == 200 and resp.json() == []
|
assert resp.status_code == 200 and resp.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_nan_quotes_in_the_monitor(client, db_session, admin_user):
|
||||||
|
"""#118: solo NaN -> la quota resta, mean null e fuori tolleranza; mista -> media dei numeri."""
|
||||||
|
sup = await _create_user(db_session, "capo", roles=["Supervisor"])
|
||||||
|
r1, s1, v1 = await _run(db_session, admin_user, "N1")
|
||||||
|
r2, s2, v2 = await _run(db_session, admin_user, "N2")
|
||||||
|
for i in range(2):
|
||||||
|
db_session.add(_m(r1, s1, v1, admin_user, None, "fail", i))
|
||||||
|
for i, (val, pf) in enumerate([(None, "fail"), (10.0, "pass"), (10.2, "pass")]):
|
||||||
|
db_session.add(_m(r2, s2, v2, admin_user, val, pf, i))
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
resp = await client.get("/api/production-runs/open", headers=auth_headers(sup))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = {r["station_code"]: r for r in resp.json()}
|
||||||
|
only_nan = body["ST-N1"]["subtasks"]
|
||||||
|
assert len(only_nan) == 1
|
||||||
|
assert only_nan[0]["mean"] is None
|
||||||
|
assert only_nan[0]["count"] == 2
|
||||||
|
assert only_nan[0]["in_tolerance"] is False
|
||||||
|
assert body["ST-N1"]["fail_count"] == 2
|
||||||
|
mixed = body["ST-N2"]["subtasks"][0]
|
||||||
|
assert mixed["count"] == 3
|
||||||
|
assert round(mixed["mean"], 6) == 10.1
|
||||||
|
assert mixed["in_tolerance"] is True
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""#118: una quota non misurata dalla visione e' una misura con value NULL (NaN), sempre fail.
|
||||||
|
|
||||||
|
Il valore NULL non deve far cadere statistiche, report, CSV e liste, e non entra
|
||||||
|
nei calcoli numerici (medie, indici, istogramma); conta solo nell'esito.
|
||||||
|
"""
|
||||||
|
import csv
|
||||||
|
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.backend.models.orm.measurement import Measurement
|
||||||
|
from src.backend.models.orm.user import User
|
||||||
|
from src.backend.services import measurement_service
|
||||||
|
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||||
|
from src.backend.tests.test_statistics import _seed_measurements
|
||||||
|
|
||||||
|
|
||||||
|
async def _recipe_with_nan(client, db_session, tec_user):
|
||||||
|
"""Una ricetta con tre misure numeriche e una NaN (value NULL, fail)."""
|
||||||
|
recipe = await create_test_recipe(db_session, tec_user.id)
|
||||||
|
subtask_id, version_id = await _seed_measurements(
|
||||||
|
client, tec_user, recipe.id, [10.0, 10.1, 9.9],
|
||||||
|
)
|
||||||
|
nan = await measurement_service.save_measurement(
|
||||||
|
db_session, subtask_id=subtask_id, version_id=version_id,
|
||||||
|
measured_by=tec_user.id, value=None, input_method="camera",
|
||||||
|
enforce_tolerance_gate=False,
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
return recipe, subtask_id, version_id, nan
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_measurement_none_is_fail_without_deviation(
|
||||||
|
client: AsyncClient, db_session: AsyncSession, measurement_tec_user: User,
|
||||||
|
):
|
||||||
|
_, _, _, nan = await _recipe_with_nan(client, db_session, measurement_tec_user)
|
||||||
|
assert nan.value is None
|
||||||
|
assert nan.pass_fail == "fail"
|
||||||
|
assert nan.deviation is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_manual_create_still_requires_a_value(
|
||||||
|
client: AsyncClient, db_session: AsyncSession, measurement_tec_user: User,
|
||||||
|
):
|
||||||
|
"""Solo il percorso visione puo' produrre NaN: la creazione manuale resta float."""
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
subtask_id, version_id = await _seed_measurements(
|
||||||
|
client, measurement_tec_user, recipe.id, [],
|
||||||
|
)
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/measurements/", headers=auth_headers(measurement_tec_user),
|
||||||
|
json={"subtask_id": subtask_id, "version_id": version_id, "value": None},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
async def test_statistics_skip_the_nan_measurement(
|
||||||
|
client: AsyncClient, db_session: AsyncSession,
|
||||||
|
measurement_tec_user: User, metrologist_user: User,
|
||||||
|
):
|
||||||
|
recipe, subtask_id, _, _ = await _recipe_with_nan(
|
||||||
|
client, db_session, measurement_tec_user,
|
||||||
|
)
|
||||||
|
params = {"recipe_id": recipe.id, "subtask_id": subtask_id}
|
||||||
|
headers = auth_headers(metrologist_user)
|
||||||
|
|
||||||
|
cap = await client.get("/api/statistics/capability", headers=headers, params=params)
|
||||||
|
assert cap.status_code == 200
|
||||||
|
assert cap.json()["n"] == 3
|
||||||
|
assert abs(cap.json()["mean"] - 10.0) < 1e-9
|
||||||
|
|
||||||
|
chart = await client.get("/api/statistics/control-chart", headers=headers, params=params)
|
||||||
|
assert chart.status_code == 200
|
||||||
|
assert len(chart.json()["values"]) == 3
|
||||||
|
|
||||||
|
hist = await client.get("/api/statistics/histogram", headers=headers, params=params)
|
||||||
|
assert hist.status_code == 200
|
||||||
|
|
||||||
|
summary = await client.get(
|
||||||
|
"/api/statistics/summary", headers=headers, params={"recipe_id": recipe.id},
|
||||||
|
)
|
||||||
|
assert summary.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
async def test_measurement_list_returns_null_value(
|
||||||
|
client: AsyncClient, db_session: AsyncSession, measurement_tec_user: User,
|
||||||
|
):
|
||||||
|
_, _, version_id, nan = await _recipe_with_nan(client, db_session, measurement_tec_user)
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/measurements/", headers=auth_headers(measurement_tec_user),
|
||||||
|
params={"version_id": version_id, "per_page": 50},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
rows = {m["id"]: m for m in resp.json()["items"]}
|
||||||
|
assert rows[nan.id]["value"] is None
|
||||||
|
assert rows[nan.id]["pass_fail"] == "fail"
|
||||||
|
assert rows[nan.id]["deviation"] is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_csv_export_writes_nan(
|
||||||
|
client: AsyncClient, db_session: AsyncSession, measurement_tec_user: User,
|
||||||
|
):
|
||||||
|
_, _, _, nan = await _recipe_with_nan(client, db_session, measurement_tec_user)
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/measurements/export/csv", headers=auth_headers(measurement_tec_user),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
rows = list(csv.reader(resp.text.splitlines()))
|
||||||
|
header = rows[0]
|
||||||
|
by_id = {r[header.index("id")]: r for r in rows[1:]}
|
||||||
|
row = by_id[str(nan.id)]
|
||||||
|
assert row[header.index("value")] == "NaN"
|
||||||
|
assert row[header.index("pass_fail")] == "fail"
|
||||||
|
assert row[header.index("deviation")] == "NaN" # #124
|
||||||
@@ -836,3 +836,31 @@ async def test_close_without_measurements_writes_no_file(
|
|||||||
)).json()
|
)).json()
|
||||||
assert closed["statistics_path"] is None
|
assert closed["statistics_path"] is None
|
||||||
assert not (tmp_path / "statistics").exists()
|
assert not (tmp_path / "statistics").exists()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_statistics_csv_writes_nan_value_and_deviation(
|
||||||
|
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||||
|
):
|
||||||
|
"""#124: una quota NaN (value NULL) esce NaN sia nel valore sia nella deviazione."""
|
||||||
|
from src.backend.services import measurement_service, production_export_service
|
||||||
|
|
||||||
|
station = await _station(db_session, admin_user.id, code="ST-NAN")
|
||||||
|
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-NAN")
|
||||||
|
opened = (await _open(client, measurement_tec_user, station, recipe)).json()
|
||||||
|
subtask = await _first_subtask(db_session, recipe.id)
|
||||||
|
await measurement_service.save_measurement(
|
||||||
|
db_session, subtask_id=subtask.id, version_id=opened["version_id"],
|
||||||
|
measured_by=measurement_tec_user.id, value=None, input_method="camera",
|
||||||
|
production_run_id=opened["id"], enforce_tolerance_gate=False,
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
run = (await db_session.execute(
|
||||||
|
select(ProductionRun).where(ProductionRun.id == opened["id"])
|
||||||
|
)).scalar_one()
|
||||||
|
|
||||||
|
text = await production_export_service.build_statistics_csv(db_session, run)
|
||||||
|
lines = [line for line in text.lstrip("").splitlines() if line.strip()]
|
||||||
|
header = lines[0].split(",")
|
||||||
|
row = lines[1].split(",")
|
||||||
|
assert row[header.index("value")] == "NaN"
|
||||||
|
assert row[header.index("deviation")] == "NaN"
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -430,3 +430,103 @@ async def test_station_vision_worker_url_must_be_http(client: AsyncClient, admin
|
|||||||
json={"vision_worker_url": "ftp://x"},
|
json={"vision_worker_url": "ftp://x"},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 422
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
# --- camera_url / camera_source (#72) ---
|
||||||
|
|
||||||
|
async def test_station_camera_fields_roundtrip(client: AsyncClient, admin_user):
|
||||||
|
created = await client.post(
|
||||||
|
"/api/stations", headers=auth_headers(admin_user),
|
||||||
|
json={
|
||||||
|
"code": "ST-CAM", "name": "CAM",
|
||||||
|
"camera_url": " http://127.0.0.1:8765/ ",
|
||||||
|
"camera_source": " camera:folder ",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert created.status_code == 201, created.text
|
||||||
|
assert created.json()["camera_url"] == "http://127.0.0.1:8765"
|
||||||
|
assert created.json()["camera_source"] == "camera:folder"
|
||||||
|
|
||||||
|
sid = created.json()["id"]
|
||||||
|
read = await client.get(f"/api/stations/{sid}", headers=auth_headers(admin_user))
|
||||||
|
assert read.status_code == 200
|
||||||
|
assert read.json()["camera_url"] == "http://127.0.0.1:8765"
|
||||||
|
assert read.json()["camera_source"] == "camera:folder"
|
||||||
|
|
||||||
|
updated = await client.put(
|
||||||
|
f"/api/stations/{sid}", headers=auth_headers(admin_user),
|
||||||
|
json={"camera_url": "https://cam.example:9000", "camera_source": "camera:ABC123"},
|
||||||
|
)
|
||||||
|
assert updated.status_code == 200
|
||||||
|
assert updated.json()["camera_url"] == "https://cam.example:9000"
|
||||||
|
assert updated.json()["camera_source"] == "camera:ABC123"
|
||||||
|
|
||||||
|
cleared = await client.put(
|
||||||
|
f"/api/stations/{sid}", headers=auth_headers(admin_user),
|
||||||
|
json={"camera_url": "", "camera_source": " "},
|
||||||
|
)
|
||||||
|
assert cleared.status_code == 200
|
||||||
|
assert cleared.json()["camera_url"] is None
|
||||||
|
assert cleared.json()["camera_source"] is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_station_camera_fields_default_to_null(client: AsyncClient, admin_user):
|
||||||
|
created = await client.post(
|
||||||
|
"/api/stations", headers=auth_headers(admin_user),
|
||||||
|
json={"code": "ST-CN", "name": "CN"},
|
||||||
|
)
|
||||||
|
assert created.status_code == 201
|
||||||
|
assert created.json()["camera_url"] is None
|
||||||
|
assert created.json()["camera_source"] is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_station_camera_url_must_be_http(client: AsyncClient, admin_user):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/stations", headers=auth_headers(admin_user),
|
||||||
|
json={"code": "ST-CF", "name": "CF", "camera_url": "ftp://x"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
created = await client.post(
|
||||||
|
"/api/stations", headers=auth_headers(admin_user),
|
||||||
|
json={"code": "ST-CG", "name": "CG"},
|
||||||
|
)
|
||||||
|
resp = await client.put(
|
||||||
|
f"/api/stations/{created.json()['id']}", headers=auth_headers(admin_user),
|
||||||
|
json={"camera_url": "ftp://x"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
async def test_station_by_code_returns_station(client: AsyncClient, admin_user):
|
||||||
|
await client.post(
|
||||||
|
"/api/stations", headers=auth_headers(admin_user),
|
||||||
|
json={"code": "ST-BC", "name": "BC", "camera_url": "http://h:1", "camera_source": "camera:folder"},
|
||||||
|
)
|
||||||
|
resp = await client.get("/api/stations/by-code/ST-BC", headers=auth_headers(admin_user))
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["code"] == "ST-BC"
|
||||||
|
assert resp.json()["camera_url"] == "http://h:1"
|
||||||
|
assert resp.json()["camera_source"] == "camera:folder"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_station_by_code_unknown_is_404(client: AsyncClient, admin_user):
|
||||||
|
resp = await client.get("/api/stations/by-code/NOPE", headers=auth_headers(admin_user))
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_station_by_code_any_authenticated_user(
|
||||||
|
client: AsyncClient, measurement_tec_user, admin_user,
|
||||||
|
):
|
||||||
|
await client.post(
|
||||||
|
"/api/stations", headers=auth_headers(admin_user),
|
||||||
|
json={"code": "ST-BA", "name": "BA"},
|
||||||
|
)
|
||||||
|
resp = await client.get("/api/stations/by-code/ST-BA", headers=auth_headers(measurement_tec_user))
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["code"] == "ST-BA"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_station_by_code_requires_auth(client: AsyncClient):
|
||||||
|
resp = await client.get("/api/stations/by-code/ST-BA")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|||||||
@@ -287,3 +287,116 @@ class TestRunFilter:
|
|||||||
headers=auth_headers(measurement_tec_user),
|
headers=auth_headers(measurement_tec_user),
|
||||||
params={"recipe_id": 1})
|
params={"recipe_id": 1})
|
||||||
assert resp.status_code == 403
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunTaskResults:
|
||||||
|
"""#58: GET /api/statistics/runs/{id}/task-results."""
|
||||||
|
|
||||||
|
FIELDS = [
|
||||||
|
{"key": "peso", "label": "Peso", "type": "number", "default": None, "attachment": False},
|
||||||
|
{"key": "foto", "label": "Foto", "type": "text", "default": None, "attachment": True},
|
||||||
|
]
|
||||||
|
|
||||||
|
async def _setup(self, client, db_session, maker_user, tec_user):
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import select
|
||||||
|
from src.backend.models.orm.production import ProductionRun
|
||||||
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
|
from src.backend.models.orm.station import Station
|
||||||
|
from src.backend.models.orm.task_result import TaskResult
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id, code="TR58")
|
||||||
|
version = (await db_session.execute(
|
||||||
|
select(RecipeVersion).where(RecipeVersion.recipe_id == recipe.id)
|
||||||
|
)).scalars().first()
|
||||||
|
db_session.add(Station(code="ST-TR58", name="S", active=True, created_by=maker_user.id))
|
||||||
|
await db_session.commit()
|
||||||
|
station = (await db_session.execute(select(Station))).scalars().first()
|
||||||
|
run = ProductionRun(
|
||||||
|
station_id=station.id, recipe_id=recipe.id, version_id=version.id,
|
||||||
|
operator_id=tec_user.id, status="open", started_at=datetime(2026, 1, 1),
|
||||||
|
)
|
||||||
|
db_session.add(run)
|
||||||
|
await db_session.commit()
|
||||||
|
h = auth_headers(maker_user)
|
||||||
|
check = (await client.post(
|
||||||
|
f"/api/recipes/{recipe.id}/tasks", headers=h,
|
||||||
|
json={"title": "Controllo", "task_type": "check"},
|
||||||
|
)).json()
|
||||||
|
note = (await client.post(
|
||||||
|
f"/api/recipes/{recipe.id}/tasks", headers=h,
|
||||||
|
json={"title": "Nota", "task_type": "note", "fields": self.FIELDS},
|
||||||
|
)).json()
|
||||||
|
# Inseriti fuori ordine: l'API deve ordinare per created_at.
|
||||||
|
rows = [
|
||||||
|
TaskResult(task_id=note["id"], version_id=version.id, production_run_id=run.id,
|
||||||
|
user_id=tec_user.id, outcome="done", note="ok finito",
|
||||||
|
values={"peso": "12", "foto": "x.jpg"},
|
||||||
|
attachments=[{"key": "foto", "path": "task_results/a.jpg", "name": "a.jpg"}],
|
||||||
|
created_at=datetime(2026, 1, 1, 10, 3)),
|
||||||
|
TaskResult(task_id=check["id"], version_id=version.id, production_run_id=run.id,
|
||||||
|
user_id=tec_user.id, outcome="nok", note="graffio",
|
||||||
|
created_at=datetime(2026, 1, 1, 10, 2)),
|
||||||
|
TaskResult(task_id=check["id"], version_id=version.id, production_run_id=run.id,
|
||||||
|
user_id=tec_user.id, outcome="ok",
|
||||||
|
created_at=datetime(2026, 1, 1, 10, 1)),
|
||||||
|
# Un esito di un'altra sessione (nessuna) non deve comparire.
|
||||||
|
TaskResult(task_id=check["id"], version_id=version.id, production_run_id=None,
|
||||||
|
user_id=tec_user.id, outcome="ok",
|
||||||
|
created_at=datetime(2026, 1, 1, 9, 0)),
|
||||||
|
]
|
||||||
|
db_session.add_all(rows)
|
||||||
|
await db_session.commit()
|
||||||
|
return recipe, run, check, note
|
||||||
|
|
||||||
|
async def test_counts_order_labels_attachments(
|
||||||
|
self, client, maker_user, measurement_tec_user, metrologist_user, db_session,
|
||||||
|
):
|
||||||
|
_, run, check, note = await self._setup(
|
||||||
|
client, db_session, maker_user, measurement_tec_user)
|
||||||
|
resp = await client.get(f"/api/statistics/runs/{run.id}/task-results",
|
||||||
|
headers=auth_headers(metrologist_user))
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
data = resp.json()
|
||||||
|
assert data["run_id"] == run.id
|
||||||
|
assert data["counts"] == {"ok": 1, "nok": 1, "done": 1}
|
||||||
|
res = data["results"]
|
||||||
|
assert [r["outcome"] for r in res] == ["ok", "nok", "done"] # per ora crescente
|
||||||
|
assert res[1]["note"] == "graffio"
|
||||||
|
assert res[1]["task_title"] == "Controllo"
|
||||||
|
assert res[1]["task_type"] == "check"
|
||||||
|
assert res[1]["user_name"] == measurement_tec_user.display_name
|
||||||
|
assert res[1]["values"] == [] and res[1]["attachments"] == []
|
||||||
|
n = res[2]
|
||||||
|
assert n["task_title"] == "Nota" and n["note"] == "ok finito"
|
||||||
|
assert n["values"] == [
|
||||||
|
{"key": "peso", "label": "Peso", "value": "12"},
|
||||||
|
{"key": "foto", "label": "Foto", "value": "x.jpg"},
|
||||||
|
]
|
||||||
|
assert n["attachments"] == [
|
||||||
|
{"key": "foto", "label": "Foto", "name": "a.jpg", "path": "task_results/a.jpg"},
|
||||||
|
]
|
||||||
|
|
||||||
|
async def test_label_falls_back_to_key_when_field_removed(
|
||||||
|
self, client, maker_user, measurement_tec_user, metrologist_user, db_session,
|
||||||
|
):
|
||||||
|
from src.backend.models.orm.task import RecipeTask
|
||||||
|
_, run, _, note = await self._setup(
|
||||||
|
client, db_session, maker_user, measurement_tec_user)
|
||||||
|
task = await db_session.get(RecipeTask, note["id"])
|
||||||
|
task.fields = [self.FIELDS[0]] # il campo «foto» viene tolto dal task
|
||||||
|
await db_session.commit()
|
||||||
|
resp = await client.get(f"/api/statistics/runs/{run.id}/task-results",
|
||||||
|
headers=auth_headers(metrologist_user))
|
||||||
|
n = resp.json()["results"][2]
|
||||||
|
assert {v["key"]: v["label"] for v in n["values"]} == {"peso": "Peso", "foto": "foto"}
|
||||||
|
assert n["attachments"][0]["label"] == "foto"
|
||||||
|
|
||||||
|
async def test_unknown_run_is_404(self, client, metrologist_user):
|
||||||
|
resp = await client.get("/api/statistics/runs/99999/task-results",
|
||||||
|
headers=auth_headers(metrologist_user))
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
async def test_measurement_tec_is_403(self, client, measurement_tec_user):
|
||||||
|
resp = await client.get("/api/statistics/runs/1/task-results",
|
||||||
|
headers=auth_headers(measurement_tec_user))
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|||||||
@@ -0,0 +1,311 @@
|
|||||||
|
"""#97: camera e modo di analisi nel task, POST /api/vision/check senza salvataggio.
|
||||||
|
|
||||||
|
Il worker e l'acquisitore non girano nei test: si fingono (fixture di
|
||||||
|
test_vision_dxf_compare e test_vision_station_worker).
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from src.backend.config import settings
|
||||||
|
from src.backend.models.orm.measurement import Measurement
|
||||||
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||||
|
from src.backend.models.orm.vision import VisionResult
|
||||||
|
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||||
|
from src.backend.tests.test_vision_copy_on_write import (
|
||||||
|
_current_task_and_subtask, _seed_measurement,
|
||||||
|
)
|
||||||
|
from src.backend.tests.test_vision_dxf_compare import ( # noqa: F401 (fixture acquisitore)
|
||||||
|
CAL, CAMERA_URL, acquisitore, redirect_uploads,
|
||||||
|
)
|
||||||
|
from src.backend.tests.test_vision_execute import an_image
|
||||||
|
from src.backend.tests.test_vision_station_worker import ( # noqa: F401 (fixture worker_calls)
|
||||||
|
_camera_task, _execute, _station_with_run, worker_calls,
|
||||||
|
)
|
||||||
|
|
||||||
|
TASK_CAMERA = "http://10.0.0.50:8300"
|
||||||
|
ACQ = {"camera_url": TASK_CAMERA, "camera_source": "camera:601020", "modo": "in_linea"}
|
||||||
|
|
||||||
|
|
||||||
|
async def _task_with_acquisition(db_session, user_id, acquisition=ACQ):
|
||||||
|
recipe, task = await _camera_task(db_session, user_id)
|
||||||
|
task.acquisition_json = acquisition
|
||||||
|
await db_session.commit()
|
||||||
|
return recipe, task
|
||||||
|
|
||||||
|
|
||||||
|
# --- modello e API del task ---------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_task_stores_and_normalizes_acquisition(client, db_session, maker_user):
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/recipes/{recipe.id}/tasks",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
json={
|
||||||
|
"title": "Camera", "task_type": "camera_measure",
|
||||||
|
"acquisition_json": {"camera_url": " http://h:1/ ", "camera_source": " camera:1 "},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
# url normalizzato come nelle stazioni, sorgente senza spazi, modo di default
|
||||||
|
expected = {"camera_url": "http://h:1", "camera_source": "camera:1", "modo": "impulsiva"}
|
||||||
|
assert body["acquisition_json"] == expected
|
||||||
|
db_session.expunge_all()
|
||||||
|
task = (await db_session.execute(
|
||||||
|
select(RecipeTask).where(RecipeTask.id == body["id"])
|
||||||
|
)).scalar_one()
|
||||||
|
assert task.acquisition_json == expected # sul DB, non solo nella risposta
|
||||||
|
got = await client.get(f"/api/tasks/{body['id']}", headers=auth_headers(maker_user))
|
||||||
|
assert got.json()["acquisition_json"] == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_task_without_acquisition_is_null(client, db_session, maker_user):
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||||
|
resp = await client.get(f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user))
|
||||||
|
assert resp.json()[0]["acquisition_json"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("bad", [
|
||||||
|
{"camera_url": "ftp://h", "camera_source": "camera:1"},
|
||||||
|
{"camera_url": " ", "camera_source": "camera:1"},
|
||||||
|
{"camera_url": "http://h", "camera_source": " "},
|
||||||
|
{"camera_url": "http://h", "camera_source": "x" * 201},
|
||||||
|
{"camera_url": "http://h", "camera_source": "camera:1", "modo": "continua"},
|
||||||
|
{"camera_source": "camera:1"},
|
||||||
|
])
|
||||||
|
async def test_invalid_acquisition_is_422(client, db_session, maker_user, bad):
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/recipes/{recipe.id}/tasks",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
json={"title": "Camera", "task_type": "camera_measure", "acquisition_json": bad},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_sets_and_clears_acquisition(client, db_session, maker_user):
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||||
|
_, task, _ = await _current_task_and_subtask(db_session, recipe.id)
|
||||||
|
resp = await client.put(
|
||||||
|
f"/api/tasks/{task.id}", headers=auth_headers(maker_user), json={"acquisition_json": ACQ},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["acquisition_json"] == ACQ
|
||||||
|
resp = await client.put(
|
||||||
|
f"/api/tasks/{task.id}", headers=auth_headers(maker_user), json={"acquisition_json": None},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["acquisition_json"] is None # null = camera della stazione
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_copy_on_write_preserves_acquisition(client, db_session, maker_user):
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||||
|
_, task, _ = await _current_task_and_subtask(db_session, recipe.id)
|
||||||
|
task.acquisition_json = ACQ
|
||||||
|
await db_session.flush()
|
||||||
|
await _seed_measurement(db_session, recipe.id, maker_user.id)
|
||||||
|
db_session.expunge_all()
|
||||||
|
|
||||||
|
resp = await client.put(
|
||||||
|
f"/api/recipes/{recipe.id}", headers=auth_headers(maker_user),
|
||||||
|
json={"change_notes": "forza il copy-on-write"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
db_session.expunge_all()
|
||||||
|
version, new_task, _ = await _current_task_and_subtask(db_session, recipe.id)
|
||||||
|
assert version.version_number == 2
|
||||||
|
assert new_task.acquisition_json == ACQ
|
||||||
|
|
||||||
|
|
||||||
|
# --- calibrazione dalla camera del task ----------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_reads_calibration_from_the_task_camera(
|
||||||
|
client, db_session, measurement_tec_user, worker_calls, acquisitore,
|
||||||
|
):
|
||||||
|
recipe, task = await _task_with_acquisition(db_session, measurement_tec_user.id)
|
||||||
|
# stazione con camera propria: la camera del task prevale
|
||||||
|
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 acquisitore["urls"] == [(f"{TASK_CAMERA}/camera/calibration", {"source": "camera:601020"})]
|
||||||
|
assert worker_calls[0]["calibration"] == {"px_per_mm": CAL["px_per_mm"]}
|
||||||
|
result = (await db_session.execute(select(VisionResult))).scalars().one()
|
||||||
|
assert result.calibration_snapshot["px_per_mm"] == CAL["px_per_mm"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_task_camera_works_without_a_run(
|
||||||
|
client, db_session, measurement_tec_user, worker_calls, acquisitore,
|
||||||
|
):
|
||||||
|
_, task = await _task_with_acquisition(db_session, measurement_tec_user.id)
|
||||||
|
await _execute(client, measurement_tec_user, task) # niente sessione di produzione
|
||||||
|
assert acquisitore["urls"][0][0] == f"{TASK_CAMERA}/camera/calibration"
|
||||||
|
assert "calibration" in worker_calls[0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_falls_back_to_the_station_camera(
|
||||||
|
client, db_session, measurement_tec_user, worker_calls, acquisitore,
|
||||||
|
):
|
||||||
|
recipe, task = await _camera_task(db_session, measurement_tec_user.id) # acquisition_json null
|
||||||
|
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 acquisitore["urls"] == [(f"{CAMERA_URL}/camera/calibration", {"source": "camera:A1"})]
|
||||||
|
assert worker_calls[0]["calibration"] == {"px_per_mm": CAL["px_per_mm"]}
|
||||||
|
|
||||||
|
|
||||||
|
# --- POST /api/vision/check ----------------------------------------------------
|
||||||
|
|
||||||
|
async def _check(client, user, task, run=None):
|
||||||
|
data = {"task_id": str(task.id)}
|
||||||
|
if run is not None:
|
||||||
|
data["production_run_id"] = str(run.id)
|
||||||
|
return await client.post(
|
||||||
|
"/api/vision/check", headers=auth_headers(user),
|
||||||
|
files={"image": ("p.png", an_image(), "image/png")}, data=data,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _counts(db_session):
|
||||||
|
return tuple([
|
||||||
|
(await db_session.execute(select(func.count()).select_from(model))).scalar_one()
|
||||||
|
for model in (Measurement, VisionResult)
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_saves_nothing_and_reports_pass_fail(
|
||||||
|
client, db_session, measurement_tec_user, monkeypatch, acquisitore,
|
||||||
|
):
|
||||||
|
_, task = await _task_with_acquisition(db_session, measurement_tec_user.id)
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
async def fake_call(image_bytes, graph, **kwargs):
|
||||||
|
seen.update(kwargs)
|
||||||
|
return {"outputs": {"t1.diameter": 11.0}, "failures": [{"tool_name": "t"}],
|
||||||
|
"overlay": [{"kind": "line"}], "outcome": "fail",
|
||||||
|
"engine_version": "e" * 40, "duration_ms": 3}
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call)
|
||||||
|
before = await _counts(db_session)
|
||||||
|
|
||||||
|
resp = await _check(client, measurement_tec_user, task)
|
||||||
|
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
quote = (await db_session.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
||||||
|
)).scalars().one()
|
||||||
|
assert body == {
|
||||||
|
"outcome": "fail", "overlay": [{"kind": "line"}], "failures": [{"tool_name": "t"}],
|
||||||
|
"measurements": [{"subtask_id": quote.id, "value": 11.0, "pass_fail": "fail"}],
|
||||||
|
"calibration": "ok",
|
||||||
|
}
|
||||||
|
assert seen["calibration"] == {"px_per_mm": CAL["px_per_mm"]} # stessa calibrazione di execute
|
||||||
|
assert await _counts(db_session) == before # niente misure ne' VisionResult
|
||||||
|
assert not list(settings.upload_path.rglob("*.overlay.json")) # ne' file
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("value,expected", [(10.0, "pass"), (10.4, "warning"), (9.0, "fail")])
|
||||||
|
async def test_check_pass_fail_levels(
|
||||||
|
client, db_session, measurement_tec_user, monkeypatch, value, expected,
|
||||||
|
):
|
||||||
|
_, task = await _camera_task(db_session, measurement_tec_user.id)
|
||||||
|
|
||||||
|
async def fake_call(image_bytes, graph, **kwargs):
|
||||||
|
return {"outputs": {"t1.diameter": value}, "failures": [], "engine_version": "e" * 40}
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call)
|
||||||
|
resp = await _check(client, measurement_tec_user, task)
|
||||||
|
assert resp.json()["measurements"][0]["pass_fail"] == expected
|
||||||
|
assert resp.json()["calibration"] == "no_camera" # senza camera ne' stazione
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_calibration_reasons(
|
||||||
|
client, db_session, measurement_tec_user, worker_calls, acquisitore,
|
||||||
|
):
|
||||||
|
_, task = await _task_with_acquisition(db_session, measurement_tec_user.id)
|
||||||
|
acquisitore["cal"] = None
|
||||||
|
assert (await _check(client, measurement_tec_user, task)).json()["calibration"] == "not_calibrated"
|
||||||
|
acquisitore["cal"] = "down"
|
||||||
|
assert (await _check(client, measurement_tec_user, task)).json()["calibration"] == "unreachable"
|
||||||
|
assert "calibration" not in worker_calls[-1] # senza calibrazione la ricetta usa la sua
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_lists_quotes_the_graph_did_not_produce_as_nan(
|
||||||
|
client, db_session, measurement_tec_user, monkeypatch,
|
||||||
|
):
|
||||||
|
_, task = await _camera_task(db_session, measurement_tec_user.id)
|
||||||
|
|
||||||
|
async def fake_call(image_bytes, graph, **kwargs):
|
||||||
|
return {"outputs": {}, "failures": [{"tool_name": "blob"}], "engine_version": "e" * 40}
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call)
|
||||||
|
resp = await _check(client, measurement_tec_user, task)
|
||||||
|
assert resp.status_code == 200 # un'anteprima non da' 422 a ogni frame
|
||||||
|
# #118: la quota non prodotta c'e', value null (NaN) e fail
|
||||||
|
measurements = resp.json()["measurements"]
|
||||||
|
assert len(measurements) == 1
|
||||||
|
assert measurements[0]["value"] is None and measurements[0]["pass_fail"] == "fail"
|
||||||
|
assert resp.json()["failures"] == [{"tool_name": "blob"}]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_422_without_graph_or_quotes_or_vision_type(
|
||||||
|
client, db_session, measurement_tec_user, worker_calls,
|
||||||
|
):
|
||||||
|
_, task = await _camera_task(db_session, measurement_tec_user.id)
|
||||||
|
task.vision_json = None
|
||||||
|
await db_session.commit()
|
||||||
|
assert (await _check(client, measurement_tec_user, task)).status_code == 422
|
||||||
|
|
||||||
|
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
||||||
|
quote = (await db_session.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
||||||
|
)).scalars().one()
|
||||||
|
quote.vision_output = None
|
||||||
|
await db_session.commit()
|
||||||
|
assert (await _check(client, measurement_tec_user, task)).status_code == 422
|
||||||
|
|
||||||
|
task.task_type = "measure"
|
||||||
|
await db_session.commit()
|
||||||
|
assert (await _check(client, measurement_tec_user, task)).status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_with_run_uses_station_worker_and_station_camera(
|
||||||
|
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, "http://10.0.0.7:8100")
|
||||||
|
station.camera_url, station.camera_source = CAMERA_URL, "camera:A1"
|
||||||
|
await db_session.commit()
|
||||||
|
before = await _counts(db_session)
|
||||||
|
resp = await _check(client, measurement_tec_user, task, run)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert worker_calls[0]["worker_url"] == "http://10.0.0.7:8100"
|
||||||
|
assert acquisitore["urls"][0][0] == f"{CAMERA_URL}/camera/calibration"
|
||||||
|
assert await _counts(db_session) == before
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_requires_login(client, db_session, measurement_tec_user, worker_calls):
|
||||||
|
_, task = await _camera_task(db_session, measurement_tec_user.id)
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/vision/check", files={"image": ("p.png", an_image(), "image/png")},
|
||||||
|
data={"task_id": str(task.id)},
|
||||||
|
)
|
||||||
|
assert resp.status_code in (401, 403)
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""#99: the vision routes accept BMP (the Opto bench cameras save it); TIFF and
|
||||||
|
non-images stay refused. files.py (attachments) is not touched by this."""
|
||||||
|
import io
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from src.backend.config import settings
|
||||||
|
from src.backend.models.orm.vision import VisionReferenceImage
|
||||||
|
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||||
|
from src.backend.tests.test_vision_upload_limits import ( # noqa: F401 - fixtures
|
||||||
|
recipe_parts, redirect_uploads, worker_says,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def a_bmp() -> bytes:
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
Image.new("L", (64, 64), color=0).save(buffer, format="BMP")
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def a_tiff() -> bytes:
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
Image.new("L", (64, 64), color=0).save(buffer, format="TIFF")
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
async def a_camera_task(db_session, user):
|
||||||
|
recipe = await create_test_recipe(db_session, user.id)
|
||||||
|
_, task, _ = await recipe_parts(db_session, recipe.id)
|
||||||
|
task.task_type = "camera_measure"
|
||||||
|
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
||||||
|
await db_session.commit()
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("content_type", ["image/bmp", "image/x-ms-bmp"])
|
||||||
|
async def test_preview_accepts_a_bmp(
|
||||||
|
client, db_session, maker_user, worker_says, content_type,
|
||||||
|
):
|
||||||
|
task = await a_camera_task(db_session, maker_user)
|
||||||
|
worker_says({"t1.diameter": 10.0})
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/vision/preview",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
files={"image": ("p.bmp", a_bmp(), content_type)},
|
||||||
|
data={"task_id": str(task.id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code != 400, response.text
|
||||||
|
assert response.status_code != 422, response.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_preview_still_rejects_a_tiff(
|
||||||
|
client, db_session, maker_user, worker_says,
|
||||||
|
):
|
||||||
|
task = await a_camera_task(db_session, maker_user)
|
||||||
|
worker_says({"t1.diameter": 10.0})
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/vision/preview",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
files={"image": ("p.tif", a_tiff(), "image/tiff")},
|
||||||
|
data={"task_id": str(task.id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_text_file_declared_bmp_is_refused_on_its_bytes(
|
||||||
|
client, db_session, maker_user, worker_says,
|
||||||
|
):
|
||||||
|
task = await a_camera_task(db_session, maker_user)
|
||||||
|
worker_says({"t1.diameter": 10.0})
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/vision/preview",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
files={"image": ("p.bmp", b"i am not an image", "image/bmp")},
|
||||||
|
data={"task_id": str(task.id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reference_image_bmp_is_saved_with_the_bmp_extension(
|
||||||
|
client, db_session, maker_user,
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||||
|
_, task, _ = await recipe_parts(db_session, recipe.id)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/vision/reference-images",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
files={"image": ("p.bmp", a_bmp(), "image/x-ms-bmp")},
|
||||||
|
data={"task_id": str(task.id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
saved = (await db_session.execute(select(VisionReferenceImage))).scalars().one()
|
||||||
|
assert saved.path.endswith(".bmp")
|
||||||
|
assert (settings.upload_path / saved.path).exists()
|
||||||
@@ -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]
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""#101: POST /api/vision/dxf-preview, anteprima del DXF nel wizard.
|
||||||
|
|
||||||
|
Il worker non gira nei test: `vision_service.dxf_preview` si sostituisce.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from src.backend.config import settings
|
||||||
|
from src.backend.services import vision_service
|
||||||
|
from src.backend.tests.conftest import auth_headers
|
||||||
|
|
||||||
|
BODY = {
|
||||||
|
"contorni": [[[0.0, 0.0], [10.0, 0.0], [10.0, 5.0], [0.0, 5.0], [0.0, 0.0]]],
|
||||||
|
"bbox": {"xmin": 0.0, "ymin": 0.0, "xmax": 10.0, "ymax": 5.0},
|
||||||
|
"larghezza_mm": 10.0, "altezza_mm": 5.0, "entita": 1, "punti": 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _post(client, user, content=b"0\nEOF"):
|
||||||
|
return client.post(
|
||||||
|
"/api/vision/dxf-preview",
|
||||||
|
headers=auth_headers(user),
|
||||||
|
files={"dxf": ("d.dxf", content, "application/octet-stream")},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_preview_forwards_the_file_and_returns_the_worker_body(
|
||||||
|
client, maker_user, monkeypatch,
|
||||||
|
):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
async def fake(dxf_bytes, worker_url=None):
|
||||||
|
calls.append(dxf_bytes)
|
||||||
|
return BODY
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.backend.services.vision_service.dxf_preview", fake)
|
||||||
|
resp = await _post(client, maker_user, b"DXF-BYTES")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json() == BODY
|
||||||
|
assert calls == [b"DXF-BYTES"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_preview_forwards_the_worker_422(client, maker_user, monkeypatch):
|
||||||
|
async def fake(dxf_bytes, worker_url=None):
|
||||||
|
raise HTTPException(status_code=422, detail="DXF illeggibile: file vuoto")
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.backend.services.vision_service.dxf_preview", fake)
|
||||||
|
resp = await _post(client, maker_user, b"")
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"] == "DXF illeggibile: file vuoto"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_preview_requires_maker(client, measurement_tec_user, monkeypatch):
|
||||||
|
async def fake(dxf_bytes, worker_url=None):
|
||||||
|
return BODY
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.backend.services.vision_service.dxf_preview", fake)
|
||||||
|
resp = await _post(client, measurement_tec_user)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_preview_too_big_is_422(client, maker_user, monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "max_upload_size_mb", 0)
|
||||||
|
resp = await _post(client, maker_user, b"x" * 10)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dxf_preview_service_posts_to_worker_and_checks_shape(monkeypatch):
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
async def fake_multipart(path, *, files, data, worker_url=None, timeout=120.0):
|
||||||
|
seen.update(path=path, files=files)
|
||||||
|
return BODY
|
||||||
|
|
||||||
|
monkeypatch.setattr(vision_service, "call_worker_multipart", fake_multipart)
|
||||||
|
assert await vision_service.dxf_preview(b"abc") == BODY
|
||||||
|
assert seen["path"] == "/dxf/preview" and seen["files"]["dxf"][1] == b"abc"
|
||||||
|
|
||||||
|
async def malformed(path, *, files, data, worker_url=None, timeout=120.0):
|
||||||
|
return {"contorni": []}
|
||||||
|
|
||||||
|
monkeypatch.setattr(vision_service, "call_worker_multipart", malformed)
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
await vision_service.dxf_preview(b"abc")
|
||||||
|
assert exc.value.status_code == 502
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
"""#105: la ricetta «Crea ricetta da DXF» si puo' riaprire e modificare.
|
||||||
|
|
||||||
|
Il worker non gira nei test: si finge come in test_vision_dxf_compare.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from src.backend.config import settings
|
||||||
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||||
|
from src.backend.services import vision_service
|
||||||
|
from src.backend.tests.conftest import auth_headers
|
||||||
|
from src.backend.tests.test_vision_dxf_compare import ( # noqa: F401 (fixture)
|
||||||
|
redirect_uploads, validate_ok,
|
||||||
|
)
|
||||||
|
from src.backend.tests.test_vision_recipe_upload import an_image, make_task
|
||||||
|
|
||||||
|
DXF = b"0\nEOF"
|
||||||
|
|
||||||
|
|
||||||
|
def _wizard_graph(band=0.3, pct=90.0, polarity="light") -> dict:
|
||||||
|
"""Grafo come lo costruisce src/vision/costruttori.py::_graph."""
|
||||||
|
return {
|
||||||
|
"schema_version": 2, "name": "Confronto DXF",
|
||||||
|
"meta": {"dxf": {"disegno": {"path": "disegno.dxf", "sha256": "x"}},
|
||||||
|
"calibration": {"path": "calibrazione.json", "sha256": "y"}},
|
||||||
|
"tools": [
|
||||||
|
{"id": "pezzo", "name": "Pezzo", "kind": "LocateBlob",
|
||||||
|
"values": {"polarity": polarity}, "links": {}},
|
||||||
|
{"id": "dxf", "name": "Confronto DXF", "kind": "CompareDxf",
|
||||||
|
"values": {"band_plus": band, "band_minus": band, "points_in_pct_nominal": pct},
|
||||||
|
"links": {}},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def w2(monkeypatch):
|
||||||
|
async def fake(image_bytes, dxf_bytes, **kwargs):
|
||||||
|
graph = _wizard_graph(kwargs["band_mm"], kwargs["min_points_pct"], kwargs["polarity"])
|
||||||
|
return {
|
||||||
|
"graph": graph,
|
||||||
|
"files": {
|
||||||
|
"disegno.dxf": base64.b64encode(dxf_bytes).decode(),
|
||||||
|
"calibrazione.json": base64.b64encode(
|
||||||
|
json.dumps({"px_per_mm": kwargs["px_per_mm"]}).encode()).decode(),
|
||||||
|
},
|
||||||
|
"outputs": {"dxf.points_in_pct": 99.0}, "overlay": [], "outcome": "pass",
|
||||||
|
}
|
||||||
|
monkeypatch.setattr("src.backend.services.vision_service.dxf_compare_recipe", fake)
|
||||||
|
|
||||||
|
|
||||||
|
def _bmp() -> bytes:
|
||||||
|
buf = io.BytesIO()
|
||||||
|
Image.new("L", (32, 32)).save(buf, format="BMP")
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _save(client, user, task, *, image=None, ctype="image/png", **data):
|
||||||
|
return client.post(
|
||||||
|
f"/api/vision/tasks/{task.id}/recipe/dxf-compare",
|
||||||
|
headers=auth_headers(user),
|
||||||
|
files={"image": ("p", image if image is not None else an_image(), ctype),
|
||||||
|
"dxf": ("d.dxf", DXF, "application/octet-stream")},
|
||||||
|
data={"save": "true", "px_per_mm": "10", **{k: str(v) for k, v in data.items()}},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_save_keeps_the_reference_image_png(client, db_session, maker_user, w2, validate_ok):
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
image = an_image()
|
||||||
|
|
||||||
|
resp = await _save(client, maker_user, task, image=image)
|
||||||
|
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
folder = settings.upload_path / resp.json()["recipe_dir"]
|
||||||
|
assert (folder / "riferimento.png").read_bytes() == image
|
||||||
|
assert (folder / "disegno.dxf").read_bytes() == DXF # il resto della cartella non cambia
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_save_keeps_the_reference_image_bmp(client, db_session, maker_user, w2, validate_ok):
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
image = _bmp()
|
||||||
|
|
||||||
|
resp = await _save(client, maker_user, task, image=image, ctype="image/bmp")
|
||||||
|
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
folder = settings.upload_path / resp.json()["recipe_dir"]
|
||||||
|
assert (folder / "riferimento.bmp").read_bytes() == image
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_source_returns_what_the_wizard_saved(client, db_session, maker_user, w2, validate_ok):
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
await _save(client, maker_user, task, band_mm=0.3, min_points_pct=90, polarity="light")
|
||||||
|
|
||||||
|
resp = await client.get(
|
||||||
|
f"/api/vision/tasks/{task.id}/recipe/dxf-compare/source", headers=auth_headers(maker_user),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json() == {
|
||||||
|
"band_mm": 0.3, "min_points_pct": 90.0, "polarity": "light", "px_per_mm": 10.0,
|
||||||
|
"dxf": "disegno.dxf", "image": "riferimento.png",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_source_404_without_recipe_or_with_a_hand_made_graph(client, db_session, maker_user):
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
url = f"/api/vision/tasks/{task.id}/recipe/dxf-compare/source"
|
||||||
|
|
||||||
|
assert (await client.get(url, headers=auth_headers(maker_user))).status_code == 404
|
||||||
|
|
||||||
|
task.vision_json = {"schema_version": 2, "name": "a mano", "tools": [], "meta": {}}
|
||||||
|
await db_session.commit()
|
||||||
|
resp = await client.get(url, headers=auth_headers(maker_user))
|
||||||
|
assert resp.status_code == 404
|
||||||
|
assert resp.json()["detail"] == "nessuna ricetta da DXF su questo task"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_source_needs_maker(client, db_session, maker_user, measurement_tec_user):
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
resp = await client.get(
|
||||||
|
f"/api/vision/tasks/{task.id}/recipe/dxf-compare/source",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_of_an_old_recipe_has_no_image_and_no_unreadable_scale(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
|
||||||
|
folder = tmp_path / "vision" / "recipes" / "1" / "abc"
|
||||||
|
folder.mkdir(parents=True)
|
||||||
|
(folder / "disegno.dxf").write_bytes(DXF)
|
||||||
|
(folder / "calibrazione.json").write_text("{non json", encoding="utf-8")
|
||||||
|
|
||||||
|
source = vision_service.dxf_compare_source(_wizard_graph(), "vision/recipes/1/abc")
|
||||||
|
|
||||||
|
assert source["image"] is None and source["px_per_mm"] is None
|
||||||
|
assert source["dxf"] == "disegno.dxf"
|
||||||
|
assert vision_service.dxf_compare_source(None, None) is None
|
||||||
|
# senza cartella i valori del grafo restano
|
||||||
|
assert vision_service.dxf_compare_source(_wizard_graph(), None)["dxf"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_files_returns_the_bytes(client, db_session, maker_user, w2, validate_ok):
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
image = _bmp()
|
||||||
|
await _save(client, maker_user, task, image=image, ctype="image/bmp")
|
||||||
|
|
||||||
|
img = await client.get(
|
||||||
|
f"/api/vision/tasks/{task.id}/recipe/files/riferimento.bmp", headers=auth_headers(maker_user),
|
||||||
|
)
|
||||||
|
dxf = await client.get(
|
||||||
|
f"/api/vision/tasks/{task.id}/recipe/files/disegno.dxf", headers=auth_headers(maker_user),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert img.status_code == 200 and img.content == image
|
||||||
|
assert img.headers["content-type"].startswith("image/")
|
||||||
|
assert dxf.status_code == 200 and dxf.content == DXF
|
||||||
|
assert dxf.headers["content-type"] # dal tipo dell'estensione, di ripiego octet-stream
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_files_404_on_unsafe_missing_or_no_recipe(client, db_session, maker_user, w2, validate_ok):
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
base = f"/api/vision/tasks/{task.id}/recipe/files/"
|
||||||
|
headers = auth_headers(maker_user)
|
||||||
|
|
||||||
|
assert (await client.get(base + "disegno.dxf", headers=headers)).status_code == 404 # nessuna ricetta
|
||||||
|
|
||||||
|
await _save(client, maker_user, task)
|
||||||
|
assert (await client.get(base + "manca.bmp", headers=headers)).status_code == 404
|
||||||
|
for unsafe in ("../x", "%2e%2e/x", "..%2f..%2fx", "sub/../../x"):
|
||||||
|
assert (await client.get(base + unsafe, headers=headers)).status_code == 404, unsafe
|
||||||
|
# la funzione non lascia uscire dalla cartella nemmeno con un assoluto
|
||||||
|
assert vision_service.recipe_file_path("vision/recipes/1/abc", "../x") is None
|
||||||
|
assert vision_service.recipe_file_path("vision/recipes/1/abc", "/etc/passwd") is None
|
||||||
|
assert vision_service.recipe_file_path("../fuori", "x") is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resave_follows_the_new_threshold(client, db_session, maker_user, w2, validate_ok):
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
task_id = task.id
|
||||||
|
await _save(client, maker_user, task, min_points_pct=95)
|
||||||
|
|
||||||
|
resp = await _save(client, maker_user, task, min_points_pct=80)
|
||||||
|
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
db_session.expire_all()
|
||||||
|
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 and float(quotes[0].ltl) == 80.0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ensure_points_quote_leaves_quote_with_measurements(
|
||||||
|
client, db_session, maker_user, w2, validate_ok, monkeypatch,
|
||||||
|
):
|
||||||
|
task, _ = await make_task(db_session, maker_user)
|
||||||
|
task_id = task.id
|
||||||
|
await _save(client, maker_user, task, min_points_pct=95)
|
||||||
|
|
||||||
|
async def has_measurements(db, version_id):
|
||||||
|
return True
|
||||||
|
monkeypatch.setattr("src.backend.services.recipe_service.version_has_measurements", has_measurements)
|
||||||
|
task = await db_session.get(RecipeTask, task_id)
|
||||||
|
|
||||||
|
changed = await vision_service.ensure_points_quote(db_session, task, 80)
|
||||||
|
|
||||||
|
assert changed is False
|
||||||
|
quote = (await db_session.execute(
|
||||||
|
select(RecipeSubtask).where(RecipeSubtask.vision_output == "dxf.points_in_pct")
|
||||||
|
.where(RecipeSubtask.task_id == task_id))).scalar_one()
|
||||||
|
assert float(quote.ltl) == 95.0
|
||||||
@@ -134,72 +134,134 @@ async def test_the_execution_records_which_engine_produced_it(
|
|||||||
assert result.graph_snapshot == task.vision_json
|
assert result.graph_snapshot == task.vision_json
|
||||||
|
|
||||||
|
|
||||||
|
# #118: una quota che il grafo non produce non e' piu' un 422: e' NaN (value
|
||||||
|
# NULL), `fail`, salvata con le altre.
|
||||||
|
async def _assert_nan_saved(client, db_session, user, task, first, second):
|
||||||
|
response = await client.post(
|
||||||
|
"/api/vision/execute",
|
||||||
|
headers=auth_headers(user),
|
||||||
|
files={"image": ("p.png", an_image(), "image/png")},
|
||||||
|
data={"task_id": str(task.id)},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert [m["subtask_id"] for m in body["measurements"]] == [first.id, second.id]
|
||||||
|
assert body["measurements"][0]["value"] is None
|
||||||
|
assert body["measurements"][0]["pass_fail"] == "fail"
|
||||||
|
assert body["measurements"][1]["value"] == GOOD
|
||||||
|
rows = (await db_session.execute(
|
||||||
|
select(Measurement).order_by(Measurement.subtask_id)
|
||||||
|
)).scalars().all()
|
||||||
|
assert len(rows) == 2
|
||||||
|
assert rows[0].value is None
|
||||||
|
assert rows[0].pass_fail == "fail"
|
||||||
|
assert rows[0].deviation is None
|
||||||
|
assert rows[0].vision_result_id is not None
|
||||||
|
assert rows[1].value is not None and rows[1].pass_fail == "pass"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_a_missing_output_names_itself(
|
async def test_a_missing_output_is_saved_as_nan(
|
||||||
client, db_session, measurement_tec_user, worker_says,
|
client, db_session, measurement_tec_user, worker_says,
|
||||||
):
|
):
|
||||||
"""No half measurement: if an output is missing, the error names it."""
|
"""#118: output assente -> 200, misura NULL/fail, le altre quote salvate."""
|
||||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
|
||||||
task.task_type = "camera_measure"
|
worker_says({"t1.radius": GOOD}, failures=[{"tool_name": "arco_tr"}])
|
||||||
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
await _assert_nan_saved(client, db_session, measurement_tec_user, task, first, second)
|
||||||
subtask.vision_output = "t1.diameter"
|
|
||||||
await db_session.commit()
|
|
||||||
worker_says({"t1.radius": 5.0})
|
@pytest.mark.asyncio
|
||||||
|
async def test_all_outputs_missing_still_saves_every_quote(
|
||||||
|
client, db_session, measurement_tec_user, worker_says,
|
||||||
|
):
|
||||||
|
"""#118: anche senza nessuna uscita, una misura NaN per quota."""
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
|
||||||
|
worker_says({})
|
||||||
|
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
"/api/vision/execute",
|
"/api/vision/execute",
|
||||||
headers=auth_headers(measurement_tec_user),
|
headers=auth_headers(measurement_tec_user),
|
||||||
files={"image": ("p.png", an_image(), "image/png")},
|
files={"image": ("p.png", an_image(), "image/png")},
|
||||||
data={"task_id": str(task.id), "lot_number": "L1"},
|
data={"task_id": str(task.id)},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 422
|
assert response.status_code == 200
|
||||||
assert "t1.diameter" in response.json()["detail"]
|
assert [m["value"] for m in response.json()["measurements"]] == [None, None]
|
||||||
assert (await db_session.execute(select(Measurement))).scalars().all() == []
|
rows = (await db_session.execute(select(Measurement))).scalars().all()
|
||||||
|
assert len(rows) == 2 and all(r.value is None and r.pass_fail == "fail" for r in rows)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_a_null_output_counts_as_not_produced(
|
async def test_a_null_output_is_saved_as_nan(
|
||||||
client, db_session, measurement_tec_user, worker_says,
|
client, db_session, measurement_tec_user, worker_says,
|
||||||
):
|
):
|
||||||
"""A worker answering with the key present but null (not omitted) must not
|
"""Chiave presente ma null (non omessa): NaN, non TypeError (#16, #118)."""
|
||||||
reach float(None) as a bare TypeError - it is "not produced", same as a
|
|
||||||
missing key (#16)."""
|
|
||||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
|
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
|
||||||
worker_says({"t1.diameter": None, "t1.radius": GOOD})
|
worker_says({"t1.diameter": None, "t1.radius": GOOD})
|
||||||
|
await _assert_nan_saved(client, db_session, measurement_tec_user, task, first, second)
|
||||||
response = await client.post(
|
|
||||||
"/api/vision/execute",
|
|
||||||
headers=auth_headers(measurement_tec_user),
|
|
||||||
files={"image": ("p.png", an_image(), "image/png")},
|
|
||||||
data={"task_id": str(task.id)},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 422
|
|
||||||
assert "t1.diameter" in response.json()["detail"]
|
|
||||||
assert (await db_session.execute(select(Measurement))).scalars().all() == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_a_non_finite_output_counts_as_not_produced(
|
async def test_a_non_finite_output_is_saved_as_nan(
|
||||||
client, db_session, measurement_tec_user, worker_says,
|
client, db_session, measurement_tec_user, worker_says,
|
||||||
):
|
):
|
||||||
"""NaN/inf must not become a saved measurement (#16)."""
|
"""NaN/inf non diventano un valore salvato: diventano NaN (NULL) (#16, #118)."""
|
||||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
|
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
|
||||||
worker_says({"t1.diameter": float("nan"), "t1.radius": GOOD})
|
worker_says({"t1.diameter": float("nan"), "t1.radius": GOOD})
|
||||||
|
await _assert_nan_saved(client, db_session, measurement_tec_user, task, first, second)
|
||||||
|
|
||||||
response = await client.post(
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_nan_quote_needs_supervisor_authorisation(
|
||||||
|
client, db_session, measurement_tec_user, worker_says,
|
||||||
|
):
|
||||||
|
"""#125: la quota NaN e' un fail come gli altri e chiede il capoturno (pending_authorisation);
|
||||||
|
la prossima acquisizione non e' bloccata."""
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
|
||||||
|
worker_says({"t1.radius": GOOD})
|
||||||
|
await _assert_nan_saved(client, db_session, measurement_tec_user, task, first, second)
|
||||||
|
|
||||||
|
blocking = await measurement_service.pending_authorisation(
|
||||||
|
db_session, version.id, measurement_tec_user.id,
|
||||||
|
)
|
||||||
|
assert blocking is not None and blocking.subtask_id == first.id and blocking.value is None
|
||||||
|
|
||||||
|
# #125: una seconda acquisizione sullo stesso task risponde 200, non e' bloccata
|
||||||
|
again = await client.post(
|
||||||
"/api/vision/execute",
|
"/api/vision/execute",
|
||||||
headers=auth_headers(measurement_tec_user),
|
headers=auth_headers(measurement_tec_user),
|
||||||
files={"image": ("p.png", an_image(), "image/png")},
|
files={"image": ("p.png", an_image(), "image/png")},
|
||||||
data={"task_id": str(task.id)},
|
data={"task_id": str(task.id)},
|
||||||
)
|
)
|
||||||
|
assert again.status_code == 200, again.text
|
||||||
|
|
||||||
assert response.status_code == 422
|
|
||||||
assert "t1.diameter" in response.json()["detail"]
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_lists_a_missing_quote_as_nan(
|
||||||
|
client, db_session, measurement_tec_user, worker_says,
|
||||||
|
):
|
||||||
|
"""#118: check -> una voce per quota, la non prodotta e' value null / fail."""
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
|
||||||
|
worker_says({"t1.radius": GOOD})
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/vision/check",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
files={"image": ("p.png", an_image(), "image/png")},
|
||||||
|
data={"task_id": str(task.id)},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["measurements"] == [
|
||||||
|
{"subtask_id": first.id, "value": None, "pass_fail": "fail"},
|
||||||
|
{"subtask_id": second.id, "value": GOOD, "pass_fail": "pass"},
|
||||||
|
]
|
||||||
assert (await db_session.execute(select(Measurement))).scalars().all() == []
|
assert (await db_session.execute(select(Measurement))).scalars().all() == []
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -242,6 +242,9 @@ def create_app() -> Flask:
|
|||||||
"company_logo": session.get("company_logo"),
|
"company_logo": session.get("company_logo"),
|
||||||
"auto_logout_minutes": session.get("auto_logout_minutes"),
|
"auto_logout_minutes": session.get("auto_logout_minutes"),
|
||||||
"current_year": date.today().year,
|
"current_year": date.today().year,
|
||||||
|
# Versione/commit nel piede di pagina (#70)
|
||||||
|
"app_version": Config.APP_VERSION,
|
||||||
|
"app_commit": Config.APP_COMMIT,
|
||||||
}
|
}
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -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=15) # #106: era 5, la prima enumerazione dopo l'avvio ha preso 3,7 s
|
||||||
|
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,10 @@ 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 urllib.parse import quote # #105: percorso del file ricetta nell'URL del backend
|
||||||
|
|
||||||
|
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
|
||||||
@@ -14,6 +17,9 @@ from services.file_proxy import proxy_file
|
|||||||
|
|
||||||
maker_bp = Blueprint("maker", __name__, url_prefix="/maker")
|
maker_bp = Blueprint("maker", __name__, url_prefix="/maker")
|
||||||
|
|
||||||
|
# #106: la prima apertura GenTL dopo l'avvio supera 15 s, il grab ha bisogno di piu margine
|
||||||
|
CAMERA_GRAB_TIMEOUT_S = 60
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# PAGINE (GET)
|
# PAGINE (GET)
|
||||||
@@ -483,6 +489,281 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
def _camera_services() -> list[dict]:
|
||||||
|
"""#97: servizi di acquisizione distinti delle stazioni che hanno una camera_url."""
|
||||||
|
services: dict[str, list[str]] = {}
|
||||||
|
for st in _stations_for_maker():
|
||||||
|
url = (st.get("camera_url") or "").strip().rstrip("/")
|
||||||
|
if not url:
|
||||||
|
continue
|
||||||
|
label = f"{st.get('code') or ''} - {st.get('name') or ''}"
|
||||||
|
services.setdefault(url, []).append(label)
|
||||||
|
return [{"camera_url": u, "stazioni": s} for u, s in services.items()]
|
||||||
|
|
||||||
|
|
||||||
|
@maker_bp.route("/api/camera/servizi", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
@maker_or_admin_required
|
||||||
|
def api_camera_services():
|
||||||
|
"""#97: servizi di acquisizione fra cui scegliere la camera di un task."""
|
||||||
|
return jsonify({"servizi": _camera_services()}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@maker_bp.route("/api/camera/sorgenti", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
@maker_or_admin_required
|
||||||
|
def api_camera_sources():
|
||||||
|
"""#97: proxy di GET {url}/camera/sources; solo per i servizi delle stazioni."""
|
||||||
|
url = (request.args.get("url") or "").strip().rstrip("/")
|
||||||
|
# Niente richieste verso indirizzi arbitrari: url ammesso solo se e' un servizio noto
|
||||||
|
if not url or url not in {s["camera_url"] for s in _camera_services()}:
|
||||||
|
return jsonify({
|
||||||
|
"error": True,
|
||||||
|
"detail": _("Servizio di acquisizione non ammesso"),
|
||||||
|
}), 400
|
||||||
|
try:
|
||||||
|
resp = http_requests.get(f"{url}/camera/sources", timeout=15)
|
||||||
|
body = resp.json() if resp.status_code == 200 else None
|
||||||
|
except (http_requests.RequestException, ValueError) as exc:
|
||||||
|
return jsonify({
|
||||||
|
"error": True,
|
||||||
|
"detail": _("Servizio camera non raggiungibile: %(detail)s", detail=str(exc)),
|
||||||
|
}), 502
|
||||||
|
sources = body.get("sources") if isinstance(body, dict) else None
|
||||||
|
if not isinstance(sources, list):
|
||||||
|
return jsonify({
|
||||||
|
"error": True,
|
||||||
|
"detail": _("Risposta del servizio camera non valida (%(status)s)", status=resp.status_code),
|
||||||
|
}), 502
|
||||||
|
return jsonify({"sources": sources}), 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=CAMERA_GRAB_TIMEOUT_S, # #106: era 15, troppo corto alla prima apertura GenTL
|
||||||
|
)
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# #105: parametri della ricetta DXF gia' salvata sul task, per riaprire il wizard compilato
|
||||||
|
@maker_bp.route("/api/vision/tasks/<int:task_id>/recipe/dxf-compare/source", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
@role_required("Maker")
|
||||||
|
def api_dxf_compare_source(task_id: int):
|
||||||
|
"""Proxy: parametri e file della ricetta DXF del task (404 se non ce n'e' una) (#105)."""
|
||||||
|
resp = api_client.get(f"/api/vision/tasks/{task_id}/recipe/dxf-compare/source")
|
||||||
|
# Codice e corpo del backend arrivano com'e': il 404 vuol dire «nessuna ricetta da DXF»
|
||||||
|
if resp.get("error"):
|
||||||
|
return jsonify(resp), resp.get("status_code", 500)
|
||||||
|
return jsonify(resp), 200
|
||||||
|
|
||||||
|
|
||||||
|
# #105: un file della cartella ricetta (DXF, immagine di riferimento) come byte, non JSON
|
||||||
|
@maker_bp.route("/api/vision/tasks/<int:task_id>/recipe/files/<path:path>", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
@role_required("Maker")
|
||||||
|
def api_vision_recipe_file(task_id: int, path: str):
|
||||||
|
"""Proxy: byte di un file della cartella ricetta del task, con content-type del backend (#105)."""
|
||||||
|
# Niente segmenti '..' o backslash: urllib3 normalizzerebbe l'URL fuori da questa rotta del backend
|
||||||
|
if "\\" in path or ".." in path.split("/"):
|
||||||
|
return jsonify({"error": True, "detail": _("File non trovato")}), 404
|
||||||
|
try:
|
||||||
|
resp = http_requests.get(
|
||||||
|
f"{Config.API_SERVER_URL.rstrip('/')}/api/vision/tasks/{task_id}/recipe/files/{quote(path)}",
|
||||||
|
headers={"X-API-Key": session.get("api_key", "")},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
except http_requests.RequestException as exc:
|
||||||
|
return jsonify({
|
||||||
|
"error": True,
|
||||||
|
"detail": _("Servizio non raggiungibile: %(detail)s", detail=str(exc)),
|
||||||
|
}), 502
|
||||||
|
return Response(
|
||||||
|
resp.content,
|
||||||
|
status=resp.status_code,
|
||||||
|
content_type=resp.headers.get("content-type", "application/octet-stream"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# #101: anteprima del DXF appena scelto nel wizard (contorni letti dal vision worker)
|
||||||
|
@maker_bp.route("/api/vision/dxf-preview", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@role_required("Maker")
|
||||||
|
def api_dxf_preview():
|
||||||
|
"""Proxy: anteprima del DXF (contorni, bbox, L x H) prima di creare la ricetta (#101)."""
|
||||||
|
dxf = request.files.get("dxf")
|
||||||
|
if dxf is None or not dxf.filename:
|
||||||
|
return jsonify({"error": True, "detail": _("Serve il file DXF")}), 400
|
||||||
|
|
||||||
|
resp = api_client.post(
|
||||||
|
"/api/vision/dxf-preview",
|
||||||
|
files={"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):
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""MeasurementTec blueprint - recipe selection and measurement execution."""
|
"""MeasurementTec blueprint - recipe selection and measurement execution."""
|
||||||
|
import json
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
import requests as http_requests
|
import requests as http_requests
|
||||||
@@ -16,6 +17,9 @@ from services.file_proxy import proxy_file
|
|||||||
|
|
||||||
measure_bp = Blueprint("measure", __name__)
|
measure_bp = Blueprint("measure", __name__)
|
||||||
|
|
||||||
|
# #106: la prima apertura GenTL dopo l'avvio supera 15 s, il grab ha bisogno di piu margine
|
||||||
|
CAMERA_GRAB_TIMEOUT_S = 60
|
||||||
|
|
||||||
# Session key holding a station chosen at runtime, overriding Config.STATION_CODE.
|
# Session key holding a station chosen at runtime, overriding Config.STATION_CODE.
|
||||||
STATION_OVERRIDE_KEY = "station_override"
|
STATION_OVERRIDE_KEY = "station_override"
|
||||||
|
|
||||||
@@ -337,6 +341,14 @@ def task_execute(task_id: int):
|
|||||||
measurement_interval_minutes = recipe_resp.get("measurement_interval_minutes")
|
measurement_interval_minutes = recipe_resp.get("measurement_interval_minutes")
|
||||||
allow_manual_input = bool(recipe_resp.get("allow_manual_input", True))
|
allow_manual_input = bool(recipe_resp.get("allow_manual_input", True))
|
||||||
|
|
||||||
|
# #97: camera del task (acquisition_json) o della stazione; il modo di analisi
|
||||||
|
# (impulsiva / in_linea) viene dal task. Solo i task visione ne hanno bisogno.
|
||||||
|
camera = None
|
||||||
|
camera_mode = "impulsiva"
|
||||||
|
if task_resp.get("task_type") in ("camera_measure", "dxf_compare"):
|
||||||
|
camera = _task_camera(task_resp, _current_station()[0])
|
||||||
|
camera_mode = _task_mode(task_resp)
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"measure/task_execute.html",
|
"measure/task_execute.html",
|
||||||
task=task_resp,
|
task=task_resp,
|
||||||
@@ -345,6 +357,8 @@ def task_execute(task_id: int):
|
|||||||
all_task_ids=all_task_ids,
|
all_task_ids=all_task_ids,
|
||||||
measurement_interval_minutes=measurement_interval_minutes,
|
measurement_interval_minutes=measurement_interval_minutes,
|
||||||
allow_manual_input=allow_manual_input,
|
allow_manual_input=allow_manual_input,
|
||||||
|
camera_enabled=camera is not None,
|
||||||
|
camera_mode=camera_mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -426,7 +440,8 @@ def task_complete(recipe_id: int):
|
|||||||
m["subtask"] = st
|
m["subtask"] = st
|
||||||
m["task_info"] = subtask_task_map.get(m.get("subtask_id"), {})
|
m["task_info"] = subtask_task_map.get(m.get("subtask_id"), {})
|
||||||
# Compute deviation if not present
|
# Compute deviation if not present
|
||||||
if m.get("deviation") is None and st.get("nominal") is not None:
|
# #118: quota non misurata (value None): la deviazione resta None (il template mostra NaN)
|
||||||
|
if m.get("deviation") is None and st.get("nominal") is not None and m.get("value") is not None:
|
||||||
try:
|
try:
|
||||||
m["deviation"] = m["value"] - st["nominal"]
|
m["deviation"] = m["value"] - st["nominal"]
|
||||||
except (TypeError, KeyError):
|
except (TypeError, KeyError):
|
||||||
@@ -1000,6 +1015,134 @@ def api_vision_execute():
|
|||||||
return jsonify(resp), 200
|
return jsonify(resp), 200
|
||||||
|
|
||||||
|
|
||||||
|
# #97: analysis of a frame without saving anything (preview before «Registra»).
|
||||||
|
@measure_bp.route("/api/vision/check", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@role_required("MeasurementTec")
|
||||||
|
def api_vision_check():
|
||||||
|
"""Proxy: run the task's VisionSuite recipe on the image, nothing is saved."""
|
||||||
|
image = request.files.get("image")
|
||||||
|
task_id = request.form.get("task_id")
|
||||||
|
if image is None or not task_id:
|
||||||
|
return jsonify({
|
||||||
|
"error": True,
|
||||||
|
"detail": _("Dati mancanti: task_id e image sono obbligatori"),
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
form = {"task_id": task_id}
|
||||||
|
if request.form.get("production_run_id"):
|
||||||
|
form["production_run_id"] = request.form["production_run_id"]
|
||||||
|
|
||||||
|
resp = api_client.post(
|
||||||
|
"/api/vision/check",
|
||||||
|
data=form,
|
||||||
|
files={"image": (
|
||||||
|
image.filename or "capture.jpg",
|
||||||
|
image.read(),
|
||||||
|
image.mimetype or "application/octet-stream",
|
||||||
|
)},
|
||||||
|
)
|
||||||
|
if resp.get("error"):
|
||||||
|
status_code = resp.get("status_code", 500)
|
||||||
|
body = {"error": True, "detail": resp.get("detail", _("Errore nell'esecuzione"))}
|
||||||
|
if resp.get("session_expired"):
|
||||||
|
body["session_expired"] = True
|
||||||
|
return jsonify(body), status_code if status_code >= 400 else 500
|
||||||
|
return jsonify(resp), 200
|
||||||
|
|
||||||
|
|
||||||
|
def _station_camera(station_code: str | None) -> tuple[str, str] | None:
|
||||||
|
"""(camera_url, camera_source) of the station, None when it has no camera.
|
||||||
|
|
||||||
|
A station that cannot be read counts as "no camera": the grab button simply
|
||||||
|
does not show, the file input keeps working.
|
||||||
|
"""
|
||||||
|
if not station_code:
|
||||||
|
return None
|
||||||
|
station = api_client.get_station_by_code(station_code)
|
||||||
|
if not isinstance(station, dict) or station.get("error"):
|
||||||
|
return None
|
||||||
|
url = station.get("camera_url")
|
||||||
|
source = station.get("camera_source")
|
||||||
|
if not url or not source:
|
||||||
|
return None
|
||||||
|
return str(url), str(source)
|
||||||
|
|
||||||
|
|
||||||
|
def _task_acquisition(task: dict | None) -> dict | None:
|
||||||
|
"""#97: acquisition_json of a task as a dict, None when absent or unreadable."""
|
||||||
|
acq = task.get("acquisition_json") if isinstance(task, dict) else None
|
||||||
|
if isinstance(acq, str):
|
||||||
|
try:
|
||||||
|
acq = json.loads(acq)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return acq if isinstance(acq, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _task_camera(task: dict | None, station_code: str | None) -> tuple[str, str] | None:
|
||||||
|
"""#97: camera of a task = its acquisition_json (url + source), else the station's."""
|
||||||
|
acq = _task_acquisition(task)
|
||||||
|
if acq and acq.get("camera_url") and acq.get("camera_source"):
|
||||||
|
return str(acq["camera_url"]), str(acq["camera_source"])
|
||||||
|
return _station_camera(station_code)
|
||||||
|
|
||||||
|
|
||||||
|
def _task_mode(task: dict | None) -> str:
|
||||||
|
"""#97: analysis mode of a task, «impulsiva» unless acquisition_json says «in_linea»."""
|
||||||
|
acq = _task_acquisition(task)
|
||||||
|
return "in_linea" if acq and acq.get("modo") == "in_linea" else "impulsiva"
|
||||||
|
|
||||||
|
|
||||||
|
# #72: the browser asks the station's camera service (vs-gui) for a frame; the
|
||||||
|
# service address is a server-side matter, so Flask relays and the PNG goes back as is.
|
||||||
|
@measure_bp.route("/api/vision/grab", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@role_required("MeasurementTec")
|
||||||
|
def api_vision_grab():
|
||||||
|
"""Proxy: grab one frame from the current station's camera, answer a PNG."""
|
||||||
|
# #97: con task_id (form o json) la camera e' quella del task, altrimenti della stazione
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
task_id = request.form.get("task_id") or payload.get("task_id")
|
||||||
|
task = None
|
||||||
|
if task_id:
|
||||||
|
try:
|
||||||
|
task = api_client.get(f"/api/tasks/{int(task_id)}")
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
task = None
|
||||||
|
camera = _task_camera(task, _current_station()[0])
|
||||||
|
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=CAMERA_GRAB_TIMEOUT_S, # #106: era 15, troppo corto alla prima apertura GenTL
|
||||||
|
)
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
@measure_bp.route("/api/vision/results/<int:result_id>/image", methods=["GET"])
|
@measure_bp.route("/api/vision/results/<int:result_id>/image", methods=["GET"])
|
||||||
@login_required
|
@login_required
|
||||||
@role_required("MeasurementTec")
|
@role_required("MeasurementTec")
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
"""Metrologist blueprint - SPC statistics dashboard and API proxies."""
|
"""Metrologist blueprint - SPC statistics dashboard and API proxies."""
|
||||||
import requests as http_requests
|
import requests as http_requests
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, render_template, request, session
|
from flask import Blueprint, abort, jsonify, render_template, request, session
|
||||||
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
|
||||||
from config import Config
|
from config import Config
|
||||||
from services.api_client import api_client
|
from services.api_client import api_client
|
||||||
|
from services.file_proxy import proxy_file # #58: allegati dei task
|
||||||
|
|
||||||
statistics_bp = Blueprint("statistics", __name__)
|
statistics_bp = Blueprint("statistics", __name__)
|
||||||
|
|
||||||
@@ -148,6 +149,33 @@ def api_runs():
|
|||||||
return jsonify(resp)
|
return jsonify(resp)
|
||||||
|
|
||||||
|
|
||||||
|
@statistics_bp.route("/api/runs/<int:run_id>/task-results")
|
||||||
|
@login_required
|
||||||
|
@role_required("Metrologist")
|
||||||
|
def api_run_task_results(run_id: int):
|
||||||
|
"""Proxy: esiti, note, campi e allegati dei task di una sessione (#58)."""
|
||||||
|
resp = api_client.get(f"/api/statistics/runs/{run_id}/task-results")
|
||||||
|
|
||||||
|
if isinstance(resp, dict) and resp.get("error"):
|
||||||
|
error_body = {"error": True, "detail": resp.get("detail", "")}
|
||||||
|
if resp.get("session_expired"):
|
||||||
|
error_body["session_expired"] = True
|
||||||
|
return jsonify(error_body), resp.get("status_code", 500)
|
||||||
|
|
||||||
|
return jsonify(resp)
|
||||||
|
|
||||||
|
|
||||||
|
@statistics_bp.route("/api/files/<path:file_path>")
|
||||||
|
@login_required
|
||||||
|
@role_required("Metrologist")
|
||||||
|
def api_task_result_file(file_path: str):
|
||||||
|
"""Proxy: allegati dei task (#58). Solo task_results/, mai altri file."""
|
||||||
|
# #58: il Metrologist non deve poter leggere altro dell'upload dir (es. statistics/).
|
||||||
|
if not file_path.startswith("task_results/") or ".." in file_path.split("/"):
|
||||||
|
abort(404)
|
||||||
|
return proxy_file(file_path)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# PROXY REPORT PDF → FastAPI
|
# PROXY REPORT PDF → FastAPI
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""TieMeasureFlow Client Configuration."""
|
"""TieMeasureFlow Client Configuration."""
|
||||||
import os
|
import os
|
||||||
|
import tomllib
|
||||||
|
from pathlib import Path
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv(os.path.join(os.path.dirname(__file__), '..', '.env'))
|
load_dotenv(os.path.join(os.path.dirname(__file__), '..', '.env'))
|
||||||
@@ -10,6 +12,25 @@ load_dotenv(os.path.join(os.path.dirname(__file__), '..', '.env'))
|
|||||||
DEFAULT_SECRET_KEY = "change-this-to-another-random-secret-key"
|
DEFAULT_SECRET_KEY = "change-this-to-another-random-secret-key"
|
||||||
|
|
||||||
|
|
||||||
|
def _read_app_version() -> str:
|
||||||
|
"""Versione del prodotto = `[project] version` di pyproject.toml (#70).
|
||||||
|
|
||||||
|
Si risale dalla cartella del modulo: nel container il file sta in
|
||||||
|
/app/pyproject.toml (Dockerfile.frontend), nel repo alla radice. Se non
|
||||||
|
si trova o e' illeggibile torna "dev": il piede di pagina non deve mai
|
||||||
|
rompere l'avvio.
|
||||||
|
"""
|
||||||
|
for parent in Path(__file__).resolve().parents:
|
||||||
|
candidate = parent / "pyproject.toml"
|
||||||
|
if candidate.is_file():
|
||||||
|
try:
|
||||||
|
with candidate.open("rb") as fh:
|
||||||
|
return str(tomllib.load(fh)["project"]["version"])
|
||||||
|
except (OSError, tomllib.TOMLDecodeError, KeyError):
|
||||||
|
return "dev"
|
||||||
|
return "dev"
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
"""Flask client configuration."""
|
"""Flask client configuration."""
|
||||||
|
|
||||||
@@ -17,6 +38,12 @@ class Config:
|
|||||||
SECRET_KEY = os.getenv("CLIENT_SECRET_KEY", DEFAULT_SECRET_KEY)
|
SECRET_KEY = os.getenv("CLIENT_SECRET_KEY", DEFAULT_SECRET_KEY)
|
||||||
DEBUG = os.getenv("FLASK_DEBUG", "0") == "1"
|
DEBUG = os.getenv("FLASK_DEBUG", "0") == "1"
|
||||||
|
|
||||||
|
# Versione e commit mostrati nel piede di pagina (#70). Letti una volta
|
||||||
|
# all'import. Il commit arriva dal build arg APP_COMMIT (.git non e' nel
|
||||||
|
# contesto di build, vedi .dockerignore); facoltativo: vuoto = non mostrato.
|
||||||
|
APP_VERSION: str = _read_app_version()
|
||||||
|
APP_COMMIT: str = os.environ.get("APP_COMMIT", "")[:7]
|
||||||
|
|
||||||
# API Server connection
|
# API Server connection
|
||||||
API_SERVER_URL = os.getenv("API_SERVER_URL", "http://localhost:8000")
|
API_SERVER_URL = os.getenv("API_SERVER_URL", "http://localhost:8000")
|
||||||
|
|
||||||
|
|||||||
@@ -193,5 +193,9 @@ class APIClient:
|
|||||||
"""Return the list of active recipes assigned to the given station."""
|
"""Return the list of active recipes assigned to the given station."""
|
||||||
return self.get(f"/api/stations/by-code/{station_code}/recipes")
|
return self.get(f"/api/stations/by-code/{station_code}/recipes")
|
||||||
|
|
||||||
|
def get_station_by_code(self, station_code: str) -> dict[str, Any]:
|
||||||
|
"""Return the station with the given code (camera_url / camera_source included)."""
|
||||||
|
return self.get(f"/api/stations/by-code/{station_code}")
|
||||||
|
|
||||||
|
|
||||||
api_client = APIClient()
|
api_client = APIClient()
|
||||||
|
|||||||
@@ -84,16 +84,18 @@ function csvExport() {
|
|||||||
const toleranceMinus = this.hasNumber(nominal) && this.hasNumber(subtask.ltl)
|
const toleranceMinus = this.hasNumber(nominal) && this.hasNumber(subtask.ltl)
|
||||||
? nominal - subtask.ltl : null;
|
? nominal - subtask.ltl : null;
|
||||||
|
|
||||||
|
// #118: quota non misurata (value null) -> NaN nel valore e nella deviazione; le tolleranze nulle restano vuote
|
||||||
|
const notMeasured = m.value === null || m.value === undefined;
|
||||||
const row = [
|
const row = [
|
||||||
m.id || '',
|
m.id || '',
|
||||||
m.subtask_id || '',
|
m.subtask_id || '',
|
||||||
this.escapeCsvValue(subtask.description || ''),
|
this.escapeCsvValue(subtask.description || ''),
|
||||||
this.formatNumber(m.value),
|
notMeasured ? 'NaN' : this.formatNumber(m.value),
|
||||||
this.escapeCsvValue(subtask.unit || 'mm'),
|
this.escapeCsvValue(subtask.unit || 'mm'),
|
||||||
this.formatNumber(nominal),
|
this.formatNumber(nominal),
|
||||||
this.formatNumber(tolerancePlus),
|
this.formatNumber(tolerancePlus),
|
||||||
this.formatNumber(toleranceMinus),
|
this.formatNumber(toleranceMinus),
|
||||||
this.formatNumber(m.deviation),
|
notMeasured ? 'NaN' : this.formatNumber(m.deviation),
|
||||||
m.pass_fail || '',
|
m.pass_fail || '',
|
||||||
this.escapeCsvValue(m.lot_number || ''),
|
this.escapeCsvValue(m.lot_number || ''),
|
||||||
this.escapeCsvValue(m.serial_number || ''),
|
this.escapeCsvValue(m.serial_number || ''),
|
||||||
|
|||||||
@@ -0,0 +1,415 @@
|
|||||||
|
// «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) {
|
||||||
|
// #121: i colori per ruolo stanno in VisionOverlay (vision_overlay.js)
|
||||||
|
const VALUE_UNITS = { 'dxf.points_in_pct': '%', 'dxf.distance_avg': 'mm', 'dxf.distance_min': 'mm', 'dxf.distance_max': 'mm' }; // #104
|
||||||
|
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: '',
|
||||||
|
imagePx: null, // #102: {w, h} in pixel, letto dall'<img> dell'anteprima
|
||||||
|
imageBroken: false, // #102: il browser non sa mostrare il formato (il file resta valido per l'invio)
|
||||||
|
calPxMm: null, // #102: px/mm della calibrazione della stazione
|
||||||
|
dxfFile: null,
|
||||||
|
dxfPreview: null, // #101: risposta di /dxf-preview {contorni, bbox, larghezza_mm, altezza_mm, entita, punti}
|
||||||
|
dxfPreviewBusy: false,
|
||||||
|
dxfPreviewError: '',
|
||||||
|
_dxfPreviewSeq: 0, // #101: una risposta arrivata dopo un cambio di file si scarta
|
||||||
|
mmPx: '', // #98: mm/px a mano (immagine da file), stringa con virgola o punto
|
||||||
|
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}
|
||||||
|
imageSource: '', // #104: '' | 'file' | 'camera'; il campo mm/px vale solo per 'file'
|
||||||
|
tested: false, // #104: true dopo una Prova (anche fallita): mostra la sezione del risultato
|
||||||
|
// #122: zoom, Adatta e pan delle tre viste (immagine, DXF, Prova) ora stanno nel componente <tmf-viewer>
|
||||||
|
runPxPerMm: null, // #104: scala usata nell'ultima Prova (null = calibrazione stazione)
|
||||||
|
runBandMm: null, // #104: banda usata nell'ultima Prova
|
||||||
|
runMinPointsPct: null, // #104: soglia usata nell'ultima Prova
|
||||||
|
existing: null, // #105: risposta di /source (ricetta DXF gia' salvata sul task); null = nessuna
|
||||||
|
existingInfo: false, // #105: «Ricetta esistente caricata...» dopo aver compilato il wizard
|
||||||
|
existingWarn: '', // #105: avviso (immagine non salvata, file illeggibili)
|
||||||
|
dxfFromRecipe: false, // #105: il DXF viene dalla ricetta (l'<input type=file> non puo' mostrarne il nome)
|
||||||
|
_existingApplied: false, // #105: il wizard e' gia' stato compilato dalla ricetta (non sovrascrivere le modifiche)
|
||||||
|
_existingReady: null, // #105: promessa della prima lettura di /source
|
||||||
|
_srcSeq: 0, // #105: una risposta di /source arrivata dopo una lettura piu' recente si scarta
|
||||||
|
_dxfPickSeq: 0, // #105: sale a ogni scelta manuale del DXF; i file della ricetta arrivati dopo si scartano
|
||||||
|
_imgPickSeq: 0, // #105: idem per l'immagine (_setImage)
|
||||||
|
|
||||||
|
// #105: all'avvio legge se il task ha gia' una ricetta da DXF (serve per l'etichetta del bottone)
|
||||||
|
init() {
|
||||||
|
if (this.taskId) this._existingReady = this.loadExisting();
|
||||||
|
},
|
||||||
|
|
||||||
|
async toggle() {
|
||||||
|
this.open = !this.open;
|
||||||
|
if (this.open) {
|
||||||
|
this.openExisting(); // #105: compila il wizard dalla ricetta esistente, in parallelo alle stazioni
|
||||||
|
if (!this.stations.length) await this.loadStations();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// #105: GET /source -> this.existing (null se 404 o errore)
|
||||||
|
async loadExisting() {
|
||||||
|
const seq = ++this._srcSeq;
|
||||||
|
let found = null;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/maker/api/vision/tasks/${this.taskId}/recipe/dxf-compare/source`);
|
||||||
|
if (resp.ok) {
|
||||||
|
const body = await resp.json().catch(() => null);
|
||||||
|
if (body && typeof body === 'object' && !body.error) found = body;
|
||||||
|
}
|
||||||
|
} catch (e) { console.error('dxfRecipeWizard source', e); }
|
||||||
|
if (seq !== this._srcSeq) return; // lettura piu' recente in corso
|
||||||
|
this.existing = found;
|
||||||
|
},
|
||||||
|
|
||||||
|
// #105: all'apertura, una sola volta, riempie il wizard con la ricetta esistente
|
||||||
|
async openExisting() {
|
||||||
|
if (this._existingReady) await this._existingReady;
|
||||||
|
if (!this.existing || this._existingApplied) return;
|
||||||
|
await this.applyExisting();
|
||||||
|
},
|
||||||
|
|
||||||
|
// #105: mm/px da px/mm, fino a 6 cifre significative e virgola decimale; '' se non valido
|
||||||
|
_mmPxFrom(pxPerMm) {
|
||||||
|
const v = Number(pxPerMm);
|
||||||
|
if (!Number.isFinite(v) || v <= 0) return '';
|
||||||
|
return String(Number((1 / v).toPrecision(6))).replace('.', ',');
|
||||||
|
},
|
||||||
|
|
||||||
|
// #105: un file della cartella ricetta -> File; null se assente o illeggibile (in quel caso avvisa)
|
||||||
|
async _fetchRecipeFile(name) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/maker/api/vision/tasks/${this.taskId}/recipe/files/${encodeURIComponent(name)}`);
|
||||||
|
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||||
|
const blob = await resp.blob();
|
||||||
|
return new File([blob], name, { type: blob.type || '' });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('dxfRecipeWizard file', name, e);
|
||||||
|
this.existingWarn = this.msgs.existingFilesFailed || 'Cannot read the recipe files';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// #105: valori salvati nei campi, poi DXF e immagine di riferimento dalla cartella ricetta
|
||||||
|
async applyExisting() {
|
||||||
|
const e = this.existing;
|
||||||
|
if (!e) return;
|
||||||
|
this._existingApplied = true;
|
||||||
|
this.error = '';
|
||||||
|
this.notice = '';
|
||||||
|
this.existingWarn = '';
|
||||||
|
const band = Number(e.band_mm), pct = Number(e.min_points_pct);
|
||||||
|
if (Number.isFinite(band) && band > 0) this.bandMm = band;
|
||||||
|
if (Number.isFinite(pct) && pct > 0) this.minPointsPct = pct;
|
||||||
|
this.dark = e.polarity === 'dark';
|
||||||
|
const mm = this._mmPxFrom(e.px_per_mm);
|
||||||
|
if (mm) this.mmPx = mm; // il campo compare quando l'immagine e' da file
|
||||||
|
this.existingInfo = true;
|
||||||
|
// Sequenze lette prima di scaricare: se l'utente sceglie un file a mano nel frattempo, vince lui
|
||||||
|
const dxfSeq = this._dxfPickSeq, imgSeq = this._imgPickSeq;
|
||||||
|
const [dxf, img] = await Promise.all([
|
||||||
|
e.dxf ? this._fetchRecipeFile(e.dxf) : null,
|
||||||
|
e.image ? this._fetchRecipeFile(e.image) : null,
|
||||||
|
]);
|
||||||
|
if (dxf && dxfSeq === this._dxfPickSeq) {
|
||||||
|
this.dxfFile = dxf;
|
||||||
|
this.dxfFromRecipe = true;
|
||||||
|
this.result = null;
|
||||||
|
this.tested = false;
|
||||||
|
this.loadDxfPreview();
|
||||||
|
}
|
||||||
|
if (img && imgSeq === this._imgPickSeq) this._setImage(img, 'file'); // 'file': il campo mm/px compare gia' compilato
|
||||||
|
if (!e.image && !this.existingWarn) this.existingWarn = this.msgs.noReferenceImage || 'Reference image not saved';
|
||||||
|
},
|
||||||
|
|
||||||
|
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.calPxMm = null;
|
||||||
|
this.result = null;
|
||||||
|
this.tested = false; // #104
|
||||||
|
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 || {});
|
||||||
|
const c = Number((r.calibration || {}).px_per_mm); // #102: scala per il campo in mm dell'anteprima
|
||||||
|
this.calPxMm = Number.isFinite(c) && c > 0 ? c : null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
get calibrated() { return this.calState === 'calibrated'; },
|
||||||
|
// #98: mm/px inserito a mano -> px/mm; null se vuoto o fuori da 0.00001..100 (limiti di /camera/calibrazioni/manuale, #95)
|
||||||
|
get manualPxMm() {
|
||||||
|
const raw = String(this.mmPx || '').trim().replace(',', '.');
|
||||||
|
if (!raw) return null;
|
||||||
|
const v = Number(raw);
|
||||||
|
if (!Number.isFinite(v) || v < 0.00001 || v > 100) return null;
|
||||||
|
return 1 / v;
|
||||||
|
},
|
||||||
|
// #98: compilato ma non valido -> errore in pagina
|
||||||
|
get mmPxInvalid() { return String(this.mmPx || '').trim() !== '' && this.manualPxMm === null; },
|
||||||
|
// #104: la scala a mano vale solo per un'immagine da file; con la camera vale la calibrazione della stazione
|
||||||
|
get useManualScale() { return this.imageSource === 'file' && this.manualPxMm !== null; },
|
||||||
|
get canRun() {
|
||||||
|
// #98: la scala a mano sostituisce la calibrazione della stazione (#104: solo per immagini da file)
|
||||||
|
return !!(this.stationId && (this.calibrated || this.useManualScale) && this.imageFile && this.dxfFile) && !this.busy;
|
||||||
|
},
|
||||||
|
|
||||||
|
_setImage(file, source) {
|
||||||
|
if (this.imageSrc) URL.revokeObjectURL(this.imageSrc);
|
||||||
|
// #102: con file null (immagine tolta) si azzera anche l'anteprima
|
||||||
|
this.imageFile = file || null;
|
||||||
|
this.imageName = (file && file.name) || '';
|
||||||
|
this.imageSrc = file ? URL.createObjectURL(file) : '';
|
||||||
|
this.imagePx = null;
|
||||||
|
this.imageBroken = false;
|
||||||
|
this.result = null;
|
||||||
|
this.tested = false; // #104
|
||||||
|
this.imageSource = file ? (source || '') : ''; // #104
|
||||||
|
this._imgPickSeq++; // #105: una scelta piu' recente batte i file della ricetta ancora in arrivo
|
||||||
|
},
|
||||||
|
|
||||||
|
// #102: il <tmf-viewer> ha caricato l'immagine (evento viewer-load): legge i pixel dal detail
|
||||||
|
// #122: lo zoom e' del componente; qui restano solo le dimensioni per il riepilogo
|
||||||
|
onImagePreviewLoad(d) {
|
||||||
|
this.imageBroken = false;
|
||||||
|
this.imagePx = d && d.width ? { w: d.width, h: d.height } : null;
|
||||||
|
},
|
||||||
|
// #102: se il formato non e' mostrabile (sonda <img hidden> del template) compare l'avviso
|
||||||
|
onImagePreviewError() {
|
||||||
|
this.imageBroken = !!this.imageSrc;
|
||||||
|
this.imagePx = null;
|
||||||
|
},
|
||||||
|
// #102: nome usato dal template (stesso valore dell'URL gia' usato per l'overlay dei risultati)
|
||||||
|
get imagePreviewUrl() { return this.imageSrc; },
|
||||||
|
// #102: «W × H px · campo ≈ L × H mm»; scala = mm/px a mano se valido, altrimenti calibrazione della stazione
|
||||||
|
get imagePreviewSummary() {
|
||||||
|
const p = this.imagePx;
|
||||||
|
if (!p) return '';
|
||||||
|
const lang = document.documentElement.lang || 'it';
|
||||||
|
const f = (v, d) => Number(v).toLocaleString(lang, { minimumFractionDigits: d, maximumFractionDigits: d });
|
||||||
|
let text = `${f(p.w, 0)} × ${f(p.h, 0)} px`;
|
||||||
|
const pxMm = this.useManualScale ? this.manualPxMm : (this.calibrated ? this.calPxMm : null); // #104
|
||||||
|
if (pxMm) text += ` · ${this.msgs.imageField || 'field'} ≈ ${f(p.w / pxMm, 2)} × ${f(p.h / pxMm, 2)} mm`;
|
||||||
|
return text;
|
||||||
|
},
|
||||||
|
|
||||||
|
onImage(ev) {
|
||||||
|
const f = ev.target.files && ev.target.files[0];
|
||||||
|
if (f) this._setImage(f, 'file'); // #104
|
||||||
|
},
|
||||||
|
onDxf(ev) {
|
||||||
|
this.dxfFile = (ev.target.files && ev.target.files[0]) || null;
|
||||||
|
this._dxfPickSeq++; // #105: scelta a mano: il DXF della ricetta in arrivo si scarta
|
||||||
|
this.dxfFromRecipe = false; // #105
|
||||||
|
this.result = null;
|
||||||
|
this.tested = false; // #104
|
||||||
|
this.loadDxfPreview(); // #101: anteprima appena si sceglie il file
|
||||||
|
},
|
||||||
|
|
||||||
|
// #101: legge il DXF sul vision worker (via Flask e backend) e ne mostra i contorni
|
||||||
|
async loadDxfPreview() {
|
||||||
|
const seq = ++this._dxfPreviewSeq;
|
||||||
|
this.dxfPreview = null;
|
||||||
|
this.dxfPreviewError = '';
|
||||||
|
this.dxfPreviewBusy = false;
|
||||||
|
if (!this.dxfFile) return; // file tolto: tutto azzerato
|
||||||
|
this.dxfPreviewBusy = true;
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('dxf', this.dxfFile, this.dxfFile.name);
|
||||||
|
const resp = await fetch('/maker/api/vision/dxf-preview', {
|
||||||
|
method: 'POST', headers: { 'X-CSRFToken': this._csrf() }, body: fd,
|
||||||
|
});
|
||||||
|
const body = await resp.json().catch(() => ({}));
|
||||||
|
if (seq !== this._dxfPreviewSeq) return; // file cambiato nel frattempo
|
||||||
|
if (!resp.ok || body.error) this.dxfPreviewError = this._errorText(body);
|
||||||
|
else {
|
||||||
|
this.dxfPreview = body;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (seq !== this._dxfPreviewSeq) return;
|
||||||
|
this.dxfPreviewError = this.msgs.requestFailed || String(e);
|
||||||
|
}
|
||||||
|
if (seq === this._dxfPreviewSeq) this.dxfPreviewBusy = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
// #101: una stringa `points` per ogni contorno, per <polyline>
|
||||||
|
dxfPreviewPaths() {
|
||||||
|
const contorni = (this.dxfPreview && this.dxfPreview.contorni) || [];
|
||||||
|
return contorni.filter(c => c && c.length > 1).map(c => c.map(p => p[0] + ',' + p[1]).join(' '));
|
||||||
|
},
|
||||||
|
|
||||||
|
// #101: tutti i contorni in un solo attributo `d` ("M x y L x y ..."): x-for non funziona dentro <svg>
|
||||||
|
dxfPreviewD() {
|
||||||
|
const contorni = (this.dxfPreview && this.dxfPreview.contorni) || [];
|
||||||
|
return contorni.filter(c => c && c.length > 1)
|
||||||
|
.map(c => c.map((p, i) => (i ? 'L ' : 'M ') + p[0] + ' ' + p[1]).join(' ')).join(' ');
|
||||||
|
},
|
||||||
|
|
||||||
|
// #101: viewBox sul bbox con margine 5% (minimo 0.5 mm per lato)
|
||||||
|
dxfPreviewViewBox() {
|
||||||
|
const b = this.dxfPreview && this.dxfPreview.bbox;
|
||||||
|
if (!b) return '0 0 1 1';
|
||||||
|
const w = b.xmax - b.xmin, h = b.ymax - b.ymin;
|
||||||
|
const m = Math.max(0.5, 0.05 * Math.max(w, h));
|
||||||
|
return [b.xmin - m, b.ymin - m, w + 2 * m, h + 2 * m].map(v => +v.toFixed(3)).join(' ');
|
||||||
|
},
|
||||||
|
|
||||||
|
// #101: «L × H mm · N entità», decimale con virgola (2 decimali) secondo la lingua pagina
|
||||||
|
get dxfPreviewSummary() {
|
||||||
|
const d = this.dxfPreview;
|
||||||
|
if (!d) return '';
|
||||||
|
const f = v => Number(v).toLocaleString(document.documentElement.lang || 'it',
|
||||||
|
{ minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
return `${f(d.larghezza_mm)} × ${f(d.altezza_mm)} mm · ${d.entita} ${this.msgs.dxfEntities || 'entities'}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 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' }), 'camera'); // #104
|
||||||
|
}
|
||||||
|
} 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 = '';
|
||||||
|
// #104: parametri usati, mostrati accanto ai risultati
|
||||||
|
this.runPxPerMm = this.useManualScale ? this.manualPxMm : (this.calibrated ? this.calPxMm : null);
|
||||||
|
this.runBandMm = this.bandMm;
|
||||||
|
this.runMinPointsPct = this.minPointsPct;
|
||||||
|
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);
|
||||||
|
// #98: scala a mano; il backend la preferisce alla calibrazione della stazione (#104: solo con immagine da file)
|
||||||
|
if (this.useManualScale) fd.append('px_per_mm', String(this.manualPxMm));
|
||||||
|
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;
|
||||||
|
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 });
|
||||||
|
// #105: il task ha ora una ricetta (etichetta «Modifica»); il wizard e' gia' allineato, non si ricompila
|
||||||
|
this._existingApplied = true;
|
||||||
|
this.existingInfo = false;
|
||||||
|
this.existingWarn = ''; // #105: l'immagine di riferimento ora e' salvata con la ricetta
|
||||||
|
await this.loadExisting();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.error = this.msgs.requestFailed || String(e);
|
||||||
|
}
|
||||||
|
this.busy = false;
|
||||||
|
// #104: la sezione del risultato compare anche con Prova fallita (immagine a sinistra, errore a destra)
|
||||||
|
this.tested = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 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], label: this._valueLabel(k), unit: VALUE_UNITS[k] })); // #104
|
||||||
|
},
|
||||||
|
|
||||||
|
// #104: etichette leggibili (testi passati da msgs), chiave grezza come ripiego
|
||||||
|
_valueLabel(k) {
|
||||||
|
const m = { 'dxf.points_in_pct': this.msgs.pointsInBand, 'dxf.distance_avg': this.msgs.distAvg,
|
||||||
|
'dxf.distance_min': this.msgs.distMin, 'dxf.distance_max': this.msgs.distMax };
|
||||||
|
return m[k] || k;
|
||||||
|
},
|
||||||
|
|
||||||
|
// #104: esito della ricetta sul riferimento: pass / warning / fail (src/vision/runner.py); altro = valore grezzo
|
||||||
|
get outcome() { return (this.result && this.result.result && this.result.result.outcome) || ''; },
|
||||||
|
get outcomeLabel() {
|
||||||
|
const m = { pass: this.msgs.outcomePass, warning: this.msgs.outcomeWarning, fail: this.msgs.outcomeFail };
|
||||||
|
return m[this.outcome] || this.outcome;
|
||||||
|
},
|
||||||
|
get outcomeClass() {
|
||||||
|
return { pass: 'text-emerald-600', warning: 'text-amber-600', fail: 'text-red-500' }[this.outcome] || 'text-[var(--text-muted)]';
|
||||||
|
},
|
||||||
|
// #104: scala usata nell'ultima Prova, con la fonte
|
||||||
|
get scaleSummary() {
|
||||||
|
if (!this.runPxPerMm) return this.msgs.scaleStation || 'station calibration';
|
||||||
|
const lang = document.documentElement.lang || 'it';
|
||||||
|
const mm = (1 / this.runPxPerMm).toLocaleString(lang, { maximumFractionDigits: 6 });
|
||||||
|
return `${mm} mm/px (${this.imageSource === 'file' ? (this.msgs.scaleManual || 'manual') : (this.msgs.scaleStation || 'station calibration')})`;
|
||||||
|
},
|
||||||
|
|
||||||
|
fmt(v) {
|
||||||
|
return (typeof v === 'number') ? v.toLocaleString(document.documentElement.lang || 'it',
|
||||||
|
{ maximumFractionDigits: 3 }) : '-';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* image_to_png.js - #107: converte nel browser le immagini che il backend non ammette (es. BMP) in PNG.
|
||||||
|
*
|
||||||
|
* Cosa fa: tmfNeedsPng(type) dice se il MIME e' un'immagine fuori da PNG/JPEG/GIF/WebP;
|
||||||
|
* tmfImageToPng(file) la ridisegna su canvas e ritorna un File PNG (null se illeggibile, es. TIFF).
|
||||||
|
* Come si usa: await tmfImageToPng(file) prima del ritaglio e del caricamento.
|
||||||
|
* Dipende da: createImageBitmap e canvas del browser (non servono nei test: il caso "nessuna conversione" e' puro).
|
||||||
|
*/
|
||||||
|
// #107: tipi che il backend ammette gia': non vanno convertiti
|
||||||
|
const TMF_PNG_PASSTHROUGH = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp'];
|
||||||
|
|
||||||
|
// #107: true per un'immagine (image/*) non ammessa dal backend, es. image/bmp
|
||||||
|
function tmfNeedsPng(type) {
|
||||||
|
return typeof type === 'string' && type.indexOf('image/') === 0 && TMF_PNG_PASSTHROUGH.indexOf(type) === -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// #107: converte in PNG con canvas; ritorna il file invariato se non serve, null se il browser non sa decodificarlo
|
||||||
|
async function tmfImageToPng(file) {
|
||||||
|
if (!file || !tmfNeedsPng(file.type)) return file;
|
||||||
|
try {
|
||||||
|
const bitmap = await createImageBitmap(file);
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = bitmap.width;
|
||||||
|
canvas.height = bitmap.height;
|
||||||
|
canvas.getContext('2d').drawImage(bitmap, 0, 0);
|
||||||
|
if (bitmap.close) bitmap.close();
|
||||||
|
const blob = await new Promise(function (resolve) { canvas.toBlob(resolve, 'image/png'); });
|
||||||
|
if (!blob) return null;
|
||||||
|
const base = (file.name || 'immagine').replace(/\.[^.]+$/, '');
|
||||||
|
return new File([blob], base + '.png', { type: 'image/png' });
|
||||||
|
} catch (err) {
|
||||||
|
// #107: formato non decodificabile dal browser (es. TIFF)
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #107: export per il test node (nel browser module non esiste)
|
||||||
|
if (typeof module !== 'undefined') module.exports = { tmfNeedsPng, tmfImageToPng };
|
||||||
@@ -0,0 +1,657 @@
|
|||||||
|
/**
|
||||||
|
* image_viewer.js - #122: <tmf-viewer>, il visualizzatore immagine standard di TieMeasureFlow.
|
||||||
|
*
|
||||||
|
* Cosa fa: Custom Element in JS puro (senza Alpine ne' Tailwind) con zoom -/+, Adatta, Salva, percentuale,
|
||||||
|
* rotella (e Ctrl+rotella) intorno al puntatore, pan col puntatore, overlay SVG a tratto costante ed etichette,
|
||||||
|
* menu dei livelli in alto a sinistra (#12).
|
||||||
|
* Sostituisce le quattro implementazioni di zoom di prima (pannello operatore #115, wizard DXF #113 e #104,
|
||||||
|
* pagina camera #111). Light DOM, stile iniettato una volta nel <head> (<style id="tmf-viewer-css">).
|
||||||
|
* Come si usa: <script> vision_overlay.js e save_image.js, poi image_viewer.js; poi
|
||||||
|
* <tmf-viewer src="..." width="1920" height="1080" save-name="task_14" fit-height></tmf-viewer>
|
||||||
|
* Attributi: src, width, height (px logici), save-name, save-src, no-save, fit-height,
|
||||||
|
* label-fit, label-save, title-in, title-out, title-save.
|
||||||
|
* Senza src, un figlio <svg> (DXF) va nello stage al 100 % (modo contenuto; Salva lo rasterizza in PNG).
|
||||||
|
* Proprieta': overlay (forme vs-tools -> VisionOverlay.svg), labels ([{key,x,y,text,cls}] in px logici),
|
||||||
|
* zoom (sola lettura); metodi fit(), zoomBy(f). Eventi (bubbling): viewer-load {width,height}, viewer-zoom {zoom,fit}.
|
||||||
|
* #12 livelli: la rotella senza Ctrl zooma sul punto del mouse. L'overlay sta in tre gruppi SVG
|
||||||
|
* (<g data-layer>): ricerca, costruzioni (opacity 0.5), quote; un menu a caselle (.tmfv-layers) accende
|
||||||
|
* Immagine, Costruzioni, Ricerca (spenta di default), Quote, Nomi. #126 attributo no-layers = viewer semplice, come
|
||||||
|
* prima dei livelli: niente menu, un solo disegno (VisionOverlay.svg di tutte le forme, poi le quote), nessun <g data-layer>
|
||||||
|
* ne' opacity, forme e immagine sempre visibili, etichette sempre accese (text) e non trascinabili (niente quota-spostata);
|
||||||
|
* zoom con rotella, pan, Adatta e Salva restano. Lo stato dei livelli non conta con no-layers. Proprieta'
|
||||||
|
* layers get/set {immagine,costruzioni,ricerca,quote,nomi}, evento viewer-layers {layers}; stato in localStorage
|
||||||
|
* (chiave tmf-viewer-layers[:save-name]). Etichette estese: {key,x,y,text,breve,cls,nome,camera,quota,testo};
|
||||||
|
* con quota stanno sul punto della scritta (VisionOverlay.quotaPunto) e si trascinano: evento quota-spostata
|
||||||
|
* {key,nome,camera,testo:[du,dv]|null} (doppio clic = testo null, posizione di default).
|
||||||
|
* In Alpine: :src="...", x-effect="$el.overlay = ...; $el.labels = ...".
|
||||||
|
* Dipende da: window.VisionOverlay (facoltativo, per l'overlay), tmfSaveImage/tmfSaveBlob/tmfSvgToPngBlob di save_image.js
|
||||||
|
* (per Salva). Il modulo si carica anche in node (require): esporta solo TmfViewerMath, funzioni pure.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var TAG = 'tmf-viewer';
|
||||||
|
var STEP = 1.25; // #122: passo dei pulsanti +/- e della rotella (come #111 e #115)
|
||||||
|
// #12: livelli del menu, titoli, valori di default e colore delle quote per esito (classi cls di stazione.js)
|
||||||
|
var LAYER_NAMES = ['immagine', 'costruzioni', 'ricerca', 'quote', 'nomi'];
|
||||||
|
var LAYER_TITLES = { immagine: 'Immagine', costruzioni: 'Costruzioni', ricerca: 'Ricerca', quote: 'Quote', nomi: 'Nomi' };
|
||||||
|
var LAYER_DEFAULT = { immagine: true, costruzioni: true, ricerca: false, quote: true, nomi: true };
|
||||||
|
var QUOTA_COLORS = { 'stz-ok': '#16a34a', 'stz-avviso': '#d97706', 'stz-ko': '#dc2626' };
|
||||||
|
var QUOTA_COLOR_DEFAULT = '#ef4444';
|
||||||
|
|
||||||
|
// #122: matematica pura dello zoom, testata in node senza browser
|
||||||
|
var TmfViewerMath = {
|
||||||
|
// zoom che fa stare tutta l'immagine (w x h) nel box (boxW x boxH); 0 se non calcolabile. boxH puo' essere Infinity
|
||||||
|
fitZoom: function (boxW, boxH, w, h) {
|
||||||
|
if (!(boxW > 0) || !(boxH > 0) || !(w > 0) || !(h > 0)) return 0;
|
||||||
|
var f = Math.min(boxW / w, boxH / h);
|
||||||
|
return isFinite(f) && f > 0 ? f : 0;
|
||||||
|
},
|
||||||
|
// zoom ristretto a [fit * 0.5, max(8, fit * 8)]: il massimo segue il fit, cosi' un DXF in mm (fit ~10) si ingrandisce ancora
|
||||||
|
clampZoom: function (z, fit) {
|
||||||
|
var lo = fit * 0.5, hi = Math.max(8, fit * 8);
|
||||||
|
return Math.min(hi, Math.max(lo, z));
|
||||||
|
},
|
||||||
|
// nuovo scroll che tiene fermo il punto (px, py) del viewport passando dallo zoom z0 a z1
|
||||||
|
zoomAround: function (z0, z1, scrollL, scrollT, px, py) {
|
||||||
|
var k = z1 / z0;
|
||||||
|
return { scrollLeft: (scrollL + px) * k - px, scrollTop: (scrollT + py) * k - py };
|
||||||
|
},
|
||||||
|
pct: function (z) { return Math.round(z * 100) + ' %'; },
|
||||||
|
};
|
||||||
|
|
||||||
|
var CSS = [
|
||||||
|
'tmf-viewer{display:block;position:relative;overflow:hidden;background:#1f2937;min-width:0}',
|
||||||
|
'tmf-viewer *{box-sizing:border-box}',
|
||||||
|
'tmf-viewer [hidden]{display:none!important}',
|
||||||
|
// #122: anche l'host con l'attributo hidden si nasconde (display:block sopra vincerebbe sul [hidden] del browser)
|
||||||
|
'tmf-viewer[hidden]{display:none!important}',
|
||||||
|
'tmf-viewer .tmfv-vp{position:absolute;top:0;right:0;bottom:0;left:0;overflow:auto;display:flex}',
|
||||||
|
'tmf-viewer[fit-height] .tmfv-vp{position:relative;width:100%}',
|
||||||
|
'tmf-viewer .tmfv-stage{position:relative;flex:none;margin:auto;user-select:none;-webkit-user-select:none}',
|
||||||
|
'tmf-viewer .tmfv-img{display:block;width:100%;height:100%;max-width:none;-webkit-user-drag:none}',
|
||||||
|
'tmf-viewer .tmfv-stage>svg:not(.tmfv-ov){display:block;width:100%;height:100%}',
|
||||||
|
'tmf-viewer .tmfv-ov{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;overflow:visible}',
|
||||||
|
'tmf-viewer .tmfv-labels{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none}',
|
||||||
|
// #122: aspetto completo dell'etichetta; le classi `cls` della pagina (es. bg-red-600 text-white) cambiano solo i colori
|
||||||
|
'tmf-viewer .tmfv-label{position:absolute;transform:translate(8px,-50%);white-space:nowrap;font:700 12px/1.3 system-ui,sans-serif;',
|
||||||
|
'padding:2px 6px;border-radius:4px;box-shadow:0 1px 3px rgba(0,0,0,.4);pointer-events:none}',
|
||||||
|
':where(tmf-viewer .tmfv-label){color:#fff;background:rgba(0,0,0,.6)}',
|
||||||
|
'tmf-viewer .tmfv-bar{position:absolute;top:6px;right:6px;z-index:3;display:flex;align-items:center;gap:2px;padding:2px;',
|
||||||
|
'background:rgba(0,0,0,.55);color:#fff;border-radius:6px;font:12px/1.4 system-ui,sans-serif}',
|
||||||
|
'tmf-viewer .tmfv-bar button{appearance:none;border:0;border-radius:4px;background:transparent;color:#fff;',
|
||||||
|
'padding:2px 8px;font:inherit;cursor:pointer;min-width:26px}',
|
||||||
|
'tmf-viewer .tmfv-bar button:hover{background:rgba(255,255,255,.22)}',
|
||||||
|
'tmf-viewer .tmfv-pct{min-width:46px;text-align:center;font-variant-numeric:tabular-nums}',
|
||||||
|
// #12: etichette con key trascinabili; menu dei livelli in alto a sinistra, stile della barra
|
||||||
|
'tmf-viewer .tmfv-label[data-key]{pointer-events:auto;cursor:move;touch-action:none}',
|
||||||
|
// #126: con no-layers le etichette non si trascinano (stessa specificita' della regola sopra, viene dopo)
|
||||||
|
'tmf-viewer[no-layers] .tmfv-label,tmf-viewer[no-layers] .tmfv-label[data-key]{pointer-events:none;cursor:default}',
|
||||||
|
'tmf-viewer .tmfv-layers{position:absolute;top:6px;left:6px;z-index:3;display:flex;flex-wrap:wrap;align-items:center;gap:2px 10px;',
|
||||||
|
'padding:3px 8px;max-width:calc(100% - 12px);background:rgba(0,0,0,.55);color:#fff;border-radius:6px;font:12px/1.4 system-ui,sans-serif}',
|
||||||
|
'tmf-viewer .tmfv-layers label{display:inline-flex;align-items:center;gap:4px;cursor:pointer;white-space:nowrap}',
|
||||||
|
'tmf-viewer .tmfv-layers input{margin:0;cursor:pointer}',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
// #122: stile iniettato una volta sola nel <head>
|
||||||
|
function injectCss() {
|
||||||
|
if (typeof document === 'undefined' || document.getElementById('tmf-viewer-css')) return;
|
||||||
|
var st = document.createElement('style');
|
||||||
|
st.id = 'tmf-viewer-css';
|
||||||
|
st.textContent = CSS;
|
||||||
|
(document.head || document.documentElement).appendChild(st);
|
||||||
|
}
|
||||||
|
|
||||||
|
function num(v) { var n = parseFloat(v); return n > 0 && isFinite(n) ? n : 0; }
|
||||||
|
|
||||||
|
// #122: dimensioni logiche di un <svg> dal viewBox, altrimenti da width/height
|
||||||
|
function svgDims(svg) {
|
||||||
|
var vb = svg.viewBox && svg.viewBox.baseVal;
|
||||||
|
if (vb && vb.width > 0 && vb.height > 0) return { w: vb.width, h: vb.height };
|
||||||
|
var a = (svg.getAttribute('viewBox') || '').trim().split(/[\s,]+/).map(Number);
|
||||||
|
if (a.length === 4 && a[2] > 0 && a[3] > 0) return { w: a[2], h: a[3] };
|
||||||
|
return { w: num(svg.getAttribute('width')), h: num(svg.getAttribute('height')) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function defineViewer() {
|
||||||
|
var OBSERVED = ['src', 'width', 'height', 'fit-height', 'no-save', 'save-name',
|
||||||
|
'label-fit', 'label-save', 'title-in', 'title-out', 'title-save', 'no-layers'];
|
||||||
|
|
||||||
|
class TmfViewer extends HTMLElement {
|
||||||
|
static get observedAttributes() { return OBSERVED; }
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this._built = false;
|
||||||
|
this._w = 0; this._h = 0; // dimensioni logiche correnti
|
||||||
|
this._nw = 0; this._nh = 0; // dimensioni naturali dell'immagine
|
||||||
|
this._z = 0; this._fit = 0; this._auto = true;
|
||||||
|
this._ov = []; this._lb = [];
|
||||||
|
this._pan = null;
|
||||||
|
this._content = null; // modo contenuto: l'<svg> adottato
|
||||||
|
this._lastEvt = '';
|
||||||
|
this._lay = Object.assign({}, LAYER_DEFAULT); // #12: livelli visibili
|
||||||
|
this._ldrag = null; // #12: trascinamento di un'etichetta in corso
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
injectCss();
|
||||||
|
if (this._built) return;
|
||||||
|
this._built = true;
|
||||||
|
// proprieta' impostate prima dell'upgrade dell'elemento: le riporto sugli accessor
|
||||||
|
var self = this;
|
||||||
|
this._layersLoad(); // #12: stato salvato, prima di eventuali proprieta' impostate dalla pagina
|
||||||
|
['overlay', 'labels', 'layers'].forEach(function (k) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(self, k)) { var v = self[k]; delete self[k]; self[k] = v; }
|
||||||
|
});
|
||||||
|
var kids = Array.prototype.slice.call(this.children);
|
||||||
|
|
||||||
|
var bar = document.createElement('div'); bar.className = 'tmfv-bar'; bar.hidden = true;
|
||||||
|
this._bOut = this._btn('−'); this._bIn = this._btn('+'); this._bFit = this._btn(''); this._bSave = this._btn('');
|
||||||
|
this._pctEl = document.createElement('span'); this._pctEl.className = 'tmfv-pct';
|
||||||
|
bar.appendChild(this._bOut); bar.appendChild(this._bIn); bar.appendChild(this._bFit);
|
||||||
|
bar.appendChild(this._bSave); bar.appendChild(this._pctEl);
|
||||||
|
// #12: menu dei livelli (caselle) in alto a sinistra
|
||||||
|
var lay = document.createElement('div'); lay.className = 'tmfv-layers'; lay.hidden = true;
|
||||||
|
this._layBoxes = {};
|
||||||
|
LAYER_NAMES.forEach(function (name) {
|
||||||
|
var lb = document.createElement('label'), cb = document.createElement('input'), tx = document.createElement('span');
|
||||||
|
cb.type = 'checkbox'; cb.setAttribute('data-layer', name); cb.checked = self._lay[name];
|
||||||
|
tx.textContent = LAYER_TITLES[name];
|
||||||
|
cb.addEventListener('change', function () { var o = {}; o[name] = cb.checked; self.layers = o; });
|
||||||
|
lb.appendChild(cb); lb.appendChild(tx); lay.appendChild(lb);
|
||||||
|
self._layBoxes[name] = cb;
|
||||||
|
});
|
||||||
|
var vp = document.createElement('div'); vp.className = 'tmfv-vp';
|
||||||
|
var stage = document.createElement('div'); stage.className = 'tmfv-stage';
|
||||||
|
var img = document.createElement('img'); img.className = 'tmfv-img'; img.draggable = false; img.alt = ''; img.hidden = true;
|
||||||
|
var ov = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||||
|
ov.setAttribute('class', 'tmfv-ov'); ov.setAttribute('preserveAspectRatio', 'none');
|
||||||
|
var labels = document.createElement('div'); labels.className = 'tmfv-labels';
|
||||||
|
stage.appendChild(img); stage.appendChild(ov); stage.appendChild(labels);
|
||||||
|
vp.appendChild(stage);
|
||||||
|
this._layBar = lay; this._bar = bar; this._vp = vp; this._stage = stage; this._img = img; this._ovEl = ov; this._lbEl = labels;
|
||||||
|
this.appendChild(bar); this.appendChild(lay); this.appendChild(vp);
|
||||||
|
|
||||||
|
this._bOut.onclick = function () { self.zoomBy(1 / STEP); };
|
||||||
|
this._bIn.onclick = function () { self.zoomBy(STEP); };
|
||||||
|
this._bFit.onclick = function () { self.fit(); };
|
||||||
|
this._bSave.onclick = function () { self._save(); };
|
||||||
|
img.addEventListener('load', function () { self._onLoad(); });
|
||||||
|
// #122: errore di caricamento -> evento viewer-error (bubbling) con l'URL che non si e' caricato
|
||||||
|
img.addEventListener('error', function () {
|
||||||
|
if (!self._hasSrc()) return; // l'errore di un src appena tolto non conta
|
||||||
|
self.dispatchEvent(new CustomEvent('viewer-error', { bubbles: true, detail: { src: self.getAttribute('src') } }));
|
||||||
|
});
|
||||||
|
// Ctrl+rotella = zoom intorno al puntatore; senza Ctrl la pagina scorre come sempre
|
||||||
|
vp.addEventListener('wheel', function (ev) { self._wheel(ev); }, { passive: false });
|
||||||
|
vp.addEventListener('pointerdown', function (ev) { self._panDown(ev); });
|
||||||
|
vp.addEventListener('pointermove', function (ev) { self._panMove(ev); });
|
||||||
|
vp.addEventListener('pointerup', function () { self._panEnd(); });
|
||||||
|
vp.addEventListener('pointercancel', function () { self._panEnd(); });
|
||||||
|
// resize del viewport: in stato «adatta» rifa Adatta; altrimenti aggiorna solo il fit
|
||||||
|
if (typeof ResizeObserver !== 'undefined') {
|
||||||
|
// #122: la callback cambia l'altezza del viewport (fit-height): rimando a un task dopo, senza «ResizeObserver loop»
|
||||||
|
this._ro = new ResizeObserver(function () {
|
||||||
|
if (self._raf) return;
|
||||||
|
var run = function () { self._raf = 0; self._onResize(); };
|
||||||
|
self._raf = setTimeout(run, 0);
|
||||||
|
});
|
||||||
|
this._ro.observe(vp);
|
||||||
|
}
|
||||||
|
// contenuto aggiunto dopo (x-html, template): lo adotto
|
||||||
|
if (typeof MutationObserver !== 'undefined') {
|
||||||
|
this._mo = new MutationObserver(function () { self._adopt(); });
|
||||||
|
this._mo.observe(this, { childList: true });
|
||||||
|
this._mo2 = new MutationObserver(function () { self._refresh(); });
|
||||||
|
}
|
||||||
|
kids.forEach(function (k) { self._take(k); });
|
||||||
|
this._texts();
|
||||||
|
this._loadSrc();
|
||||||
|
this._refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
// gli osservatori restano: se l'elemento viene ricollegato il viewport e' lo stesso
|
||||||
|
}
|
||||||
|
|
||||||
|
attributeChangedCallback(name, oldV, newV) {
|
||||||
|
if (!this._built || oldV === newV) return;
|
||||||
|
if (name === 'src') this._loadSrc();
|
||||||
|
else if (name === 'width' || name === 'height') this._refresh();
|
||||||
|
else if (name === 'fit-height') { if (!this.hasAttribute('fit-height')) this._vp.style.height = ''; this._onResize(true); }
|
||||||
|
else if (name === 'no-layers') { this._syncLayers(); this._drawOverlay(); this._drawLabels(); } // #12 #126: ridisegna al cambio
|
||||||
|
else this._texts();
|
||||||
|
}
|
||||||
|
|
||||||
|
// #126: viewer senza livelli (attributo no-layers): disegno semplice, etichette fisse
|
||||||
|
_noLay() { return this.hasAttribute('no-layers'); }
|
||||||
|
|
||||||
|
_btn(txt) {
|
||||||
|
var b = document.createElement('button');
|
||||||
|
b.type = 'button'; b.textContent = txt;
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
// #122: testi e titoli dei pulsanti dagli attributi
|
||||||
|
_texts() {
|
||||||
|
var g = function (el, n, d) { var v = el.getAttribute(n); return v === null || v === '' ? d : v; };
|
||||||
|
this._bFit.textContent = g(this, 'label-fit', 'Adatta');
|
||||||
|
this._bSave.textContent = g(this, 'label-save', 'Salva');
|
||||||
|
this._bIn.title = g(this, 'title-in', 'Ingrandisci');
|
||||||
|
this._bOut.title = g(this, 'title-out', 'Riduci');
|
||||||
|
this._bSave.title = g(this, 'title-save', 'Salva immagine');
|
||||||
|
this._bSave.hidden = this.hasAttribute('no-save');
|
||||||
|
}
|
||||||
|
|
||||||
|
// #122: adotta un figlio <svg> come contenuto (modo contenuto, DXF)
|
||||||
|
_take(el) {
|
||||||
|
if (!el || el === this._bar || el === this._layBar || el === this._vp || el.parentNode !== this) return;
|
||||||
|
if (String(el.tagName).toLowerCase() !== 'svg') return;
|
||||||
|
if (this._content && this._content !== el && this._content.parentNode) this._content.parentNode.removeChild(this._content);
|
||||||
|
this._content = el;
|
||||||
|
this._stage.insertBefore(el, this._ovEl);
|
||||||
|
if (this._mo2) this._mo2.observe(el, { attributes: true, attributeFilter: ['viewBox', 'width', 'height'] });
|
||||||
|
}
|
||||||
|
|
||||||
|
_adopt() {
|
||||||
|
var kids = Array.prototype.slice.call(this.children), self = this, found = false;
|
||||||
|
kids.forEach(function (k) { if (k !== self._bar && k !== self._layBar && k !== self._vp) { self._take(k); found = true; } });
|
||||||
|
if (found) this._refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
// #122: (ri)carica l'immagine dall'attributo src
|
||||||
|
_loadSrc() {
|
||||||
|
var src = this.getAttribute('src');
|
||||||
|
if (src) {
|
||||||
|
this._img.hidden = false;
|
||||||
|
this._img.src = src;
|
||||||
|
} else {
|
||||||
|
this._img.removeAttribute('src');
|
||||||
|
this._img.hidden = true;
|
||||||
|
this._nw = 0; this._nh = 0;
|
||||||
|
this._refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_onLoad() {
|
||||||
|
var img = this._img;
|
||||||
|
this._nw = img.naturalWidth; this._nh = img.naturalHeight;
|
||||||
|
this._refresh();
|
||||||
|
this.dispatchEvent(new CustomEvent('viewer-load', { bubbles: true, detail: { width: this._w, height: this._h } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
_hasSrc() { return !!this.getAttribute('src'); }
|
||||||
|
|
||||||
|
// #122: ricalcola le dimensioni logiche; Adatta se cambiano (o se la vista era ad Adatta), zoom mantenuto altrimenti
|
||||||
|
_refresh() {
|
||||||
|
if (!this._built) return;
|
||||||
|
var w = num(this.getAttribute('width')), h = num(this.getAttribute('height'));
|
||||||
|
if (this._hasSrc()) {
|
||||||
|
if (!w) w = this._nw;
|
||||||
|
if (!h) h = this._nh;
|
||||||
|
if (!this._nw || !this._nh) { if (!(w && h)) { w = 0; h = 0; } }
|
||||||
|
} else if (this._content) {
|
||||||
|
var d = svgDims(this._content);
|
||||||
|
if (!w) w = d.w;
|
||||||
|
if (!h) h = d.h;
|
||||||
|
} else { w = 0; h = 0; }
|
||||||
|
if (!(w > 0 && h > 0)) { w = 0; h = 0; }
|
||||||
|
var changed = w !== this._w || h !== this._h;
|
||||||
|
this._w = w; this._h = h;
|
||||||
|
this._bar.hidden = !w;
|
||||||
|
this._img.hidden = !this._hasSrc();
|
||||||
|
if (this._content) this._content.style.display = this._hasSrc() ? 'none' : '';
|
||||||
|
if (!w) { this._z = 0; this._fit = 0; this._auto = true; this._apply(); return; }
|
||||||
|
if (changed || this._auto || !this._z) this.fit(); else this._apply();
|
||||||
|
if (!this._z) this._apply(true); // riquadro nascosto: Adatta rimandata, ma overlay ed etichette (in %) si disegnano gia'
|
||||||
|
}
|
||||||
|
|
||||||
|
// #122: area utile del viewport: offset meno i bordi (client* escludono le barre che spariranno dopo l'Adatta)
|
||||||
|
_area() {
|
||||||
|
var vp = this._vp, cw = vp.clientWidth, ch = vp.clientHeight, bx = 0, by = 0;
|
||||||
|
if (vp.offsetWidth && typeof getComputedStyle === 'function') {
|
||||||
|
var cs = getComputedStyle(vp);
|
||||||
|
bx = (parseFloat(cs.borderLeftWidth) + parseFloat(cs.borderRightWidth)) || 0;
|
||||||
|
by = (parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth)) || 0;
|
||||||
|
cw = vp.offsetWidth - bx;
|
||||||
|
ch = vp.offsetHeight - by;
|
||||||
|
}
|
||||||
|
return { cw: cw, ch: ch, by: by };
|
||||||
|
}
|
||||||
|
|
||||||
|
// zoom di Adatta per l'area corrente; 0 se non calcolabile (riquadro nascosto o vuoto)
|
||||||
|
_calcFit() {
|
||||||
|
if (!this._w) return 0;
|
||||||
|
var a = this._area();
|
||||||
|
if (this.hasAttribute('fit-height')) return TmfViewerMath.fitZoom(a.cw, Infinity, this._w, this._h);
|
||||||
|
return TmfViewerMath.fitZoom(a.cw, a.ch, this._w, this._h);
|
||||||
|
}
|
||||||
|
|
||||||
|
// #122: altezza del viewport in fit-height: segue l'immagine adattata alla larghezza
|
||||||
|
_fitHeightBox(fit) {
|
||||||
|
if (!this.hasAttribute('fit-height')) return;
|
||||||
|
this._vp.style.height = fit > 0 ? (this._h * fit + this._area().by) + 'px' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
fit() {
|
||||||
|
if (!this._built || !this._w) return;
|
||||||
|
var f = this._calcFit();
|
||||||
|
if (!(f > 0)) return; // riquadro nascosto: ci pensa l'osservatore
|
||||||
|
this._fit = f; this._z = f; this._auto = true;
|
||||||
|
this._fitHeightBox(f);
|
||||||
|
this._vp.scrollLeft = 0; this._vp.scrollTop = 0;
|
||||||
|
this._apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
// resize del viewport: Adatta se la vista non e' stata zoomata, altrimenti fit aggiornato e zoom nei limiti
|
||||||
|
_onResize(force) {
|
||||||
|
if (!this._built || !this._w) return;
|
||||||
|
var f = this._calcFit();
|
||||||
|
if (!(f > 0)) return;
|
||||||
|
if (!force && Math.abs(f - this._fit) < 1e-9 && this._z) return;
|
||||||
|
if (this._auto) { this.fit(); return; }
|
||||||
|
this._fit = f;
|
||||||
|
this._z = TmfViewerMath.clampZoom(this._z, f);
|
||||||
|
this._fitHeightBox(f);
|
||||||
|
this._apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
// #122: zoom di un fattore f tenendo fermo il punto (px, py) del viewport (default: il centro)
|
||||||
|
zoomBy(f, px, py, ancora) { // `ancora` {ix, iy} (interna, #12): punto immagine da tenere sotto (px, py)
|
||||||
|
if (!this._built || !this._w || !this._z) return;
|
||||||
|
var vp = this._vp, z0 = this._z;
|
||||||
|
var z1 = TmfViewerMath.clampZoom(z0 * f, this._fit);
|
||||||
|
if (z1 === z0) return;
|
||||||
|
if (px == null) px = vp.clientWidth / 2;
|
||||||
|
if (py == null) py = vp.clientHeight / 2;
|
||||||
|
// #12: punto immagine sotto (px, py) misurato sullo stage reale, cosi' resta fermo anche quando lo stage e' centrato
|
||||||
|
// dal margin:auto (immagine piu' piccola del viewport prima dello zoom); zoomAround copre il caso senza geometria
|
||||||
|
var sr = this._stage.getBoundingClientRect(), vr = vp.getBoundingClientRect();
|
||||||
|
var ix = (px - (sr.left - vr.left - vp.clientLeft)) / z0, iy = (py - (sr.top - vr.top - vp.clientTop)) / z0;
|
||||||
|
// #12: con l'ancora di una sequenza di rotella si riporta sotto il mouse il punto di partenza, non quello corrente
|
||||||
|
if (ancora && isFinite(ancora.ix) && isFinite(ancora.iy)) { ix = ancora.ix; iy = ancora.iy; }
|
||||||
|
this._zpt = { ix: ix, iy: iy };
|
||||||
|
this._z = z1; this._auto = false;
|
||||||
|
this._apply(); // la dimensione dello stage cambia subito, poi si puo' scorrere
|
||||||
|
var sr2 = this._stage.getBoundingClientRect(), vr2 = vp.getBoundingClientRect();
|
||||||
|
if (sr.width > 0 && isFinite(ix) && isFinite(iy)) {
|
||||||
|
vp.scrollLeft += (sr2.left - vr2.left - vp.clientLeft + ix * z1) - px;
|
||||||
|
vp.scrollTop += (sr2.top - vr2.top - vp.clientTop + iy * z1) - py;
|
||||||
|
} else {
|
||||||
|
var s = TmfViewerMath.zoomAround(z0, z1, vp.scrollLeft, vp.scrollTop, px, py);
|
||||||
|
vp.scrollLeft = s.scrollLeft; vp.scrollTop = s.scrollTop;
|
||||||
|
}
|
||||||
|
this._apply(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
get zoom() { return this._z; }
|
||||||
|
|
||||||
|
// #122: applica zoom allo stage, ridisegna overlay ed etichette, aggiorna barra e cursore, avvisa con viewer-zoom
|
||||||
|
_apply(quiet) {
|
||||||
|
if (!this._built) return;
|
||||||
|
var z = this._z;
|
||||||
|
this._stage.style.width = this._w && z ? (this._w * z) + 'px' : '0px';
|
||||||
|
this._stage.style.height = this._w && z ? (this._h * z) + 'px' : '0px';
|
||||||
|
// #122: modo contenuto (DXF in mm) = percentuale rispetto ad Adatta; immagine = 100 % un pixel per pixel
|
||||||
|
this._pctEl.textContent = TmfViewerMath.pct(!this._hasSrc() && this._content && this._fit ? z / this._fit : z);
|
||||||
|
this._drawOverlay();
|
||||||
|
this._drawLabels();
|
||||||
|
this._syncLayers(); // #12
|
||||||
|
this._cursor();
|
||||||
|
if (quiet) return;
|
||||||
|
var key = z + '|' + this._fit;
|
||||||
|
if (key !== this._lastEvt) {
|
||||||
|
this._lastEvt = key;
|
||||||
|
this.dispatchEvent(new CustomEvent('viewer-zoom', { bubbles: true, detail: { zoom: z, fit: this._fit } }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_canPan() {
|
||||||
|
var vp = this._vp;
|
||||||
|
return this._w > 0 && (vp.scrollWidth > vp.clientWidth + 1 || vp.scrollHeight > vp.clientHeight + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
_cursor() {
|
||||||
|
var can = this._canPan();
|
||||||
|
this._vp.style.cursor = can ? (this._pan ? 'grabbing' : 'grab') : 'default';
|
||||||
|
this._vp.style.touchAction = can ? 'none' : 'auto'; // il dito trascina solo se c'e' qualcosa da scorrere
|
||||||
|
}
|
||||||
|
|
||||||
|
_wheel(ev) { // Ctrl+rotella (anche il pinch del trackpad) fa lo stesso
|
||||||
|
// #12: la rotella zooma sempre sul punto del mouse (prima solo con Ctrl); senza immagine la pagina scorre
|
||||||
|
if (!this._w) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
var r = this._vp.getBoundingClientRect(), now = Date.now(), wa = this._wa;
|
||||||
|
// #12: sequenza di scatti a mouse fermo (<= 2 px, < 1500 ms) = stessa ancora; altrimenti la nuova ancora si misura in zoomBy.
|
||||||
|
// Cosi' lo scarto accumulato mentre lo stage e' piu' stretto del viewport (centrato, scroll impossibile) si recupera dopo.
|
||||||
|
var riusa = wa && Math.abs(ev.clientX - wa.cx) <= 2 && Math.abs(ev.clientY - wa.cy) <= 2 && now - wa.t < 1500;
|
||||||
|
this._zpt = null;
|
||||||
|
this.zoomBy(ev.deltaY < 0 ? STEP : 1 / STEP, ev.clientX - r.left - this._vp.clientLeft, ev.clientY - r.top - this._vp.clientTop,
|
||||||
|
riusa ? wa : null);
|
||||||
|
var pt = this._zpt || (riusa ? wa : null);
|
||||||
|
this._wa = pt ? { cx: ev.clientX, cy: ev.clientY, t: now, ix: pt.ix, iy: pt.iy } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_panDown(ev) {
|
||||||
|
var vp = this._vp;
|
||||||
|
if (!this._canPan() || (ev.pointerType === 'mouse' && ev.button)) return;
|
||||||
|
ev.preventDefault(); // senza, Chromium manda pointercancel al primo movimento
|
||||||
|
this._wa = null; // #12: il pan cambia il punto sotto il mouse: niente ancora della rotella
|
||||||
|
this._pan = { x: ev.clientX, y: ev.clientY, left: vp.scrollLeft, top: vp.scrollTop };
|
||||||
|
this._cursor();
|
||||||
|
try { vp.setPointerCapture(ev.pointerId); } catch (e) { /* puntatore gia' rilasciato */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
_panMove(ev) {
|
||||||
|
var p = this._pan;
|
||||||
|
if (!p) return;
|
||||||
|
this._vp.scrollLeft = p.left - (ev.clientX - p.x);
|
||||||
|
this._vp.scrollTop = p.top - (ev.clientY - p.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
_panEnd() { this._pan = null; this._cursor(); }
|
||||||
|
|
||||||
|
// #122: overlay SVG a tratto costante; ridisegnato a ogni zoom perche' le croci dei points restano a dimensione fissa.
|
||||||
|
// #12: tre gruppi <g data-layer> (ricerca, costruzioni a opacity 0.5, quote); un livello spento ha display none.
|
||||||
|
_drawOverlay() {
|
||||||
|
var ov = this._ovEl, VO = typeof window !== 'undefined' ? window.VisionOverlay : null;
|
||||||
|
if (!this._w) { ov.innerHTML = ''; return; }
|
||||||
|
ov.setAttribute('viewBox', '0 0 ' + this._w + ' ' + this._h);
|
||||||
|
if (!VO) { ov.innerHTML = ''; return; }
|
||||||
|
var z = this._z || 1, lay = this._lay, noLay = this._noLay(); // #126
|
||||||
|
var by = { ricerca: [], costruzioni: [], quote: [] };
|
||||||
|
(this._ov || []).forEach(function (o) {
|
||||||
|
var k = VO.layerOf ? VO.layerOf(o) : (o && o.role === 'found' ? 'costruzioni' : 'ricerca');
|
||||||
|
by[k].push(o);
|
||||||
|
});
|
||||||
|
var quote = noLay ? '' : VO.svg(by.quote, { zoom: z }); // #126: con no-layers le forme vanno tutte nel disegno unico sotto
|
||||||
|
(this._lb || []).forEach(function (l) {
|
||||||
|
if (!l) return;
|
||||||
|
var col = QUOTA_COLOR_DEFAULT;
|
||||||
|
String(l.cls || '').split(/\s+/).forEach(function (c) { if (QUOTA_COLORS[c]) col = QUOTA_COLORS[c]; });
|
||||||
|
if (l.quota && VO.quotaSvg) { quote += VO.quotaSvg(l.quota, { zoom: z, color: col }); return; }
|
||||||
|
var t = l.testo;
|
||||||
|
if (Array.isArray(t) && t.length === 2 && isFinite(t[0]) && isFinite(t[1]) && isFinite(l.x) && isFinite(l.y)) {
|
||||||
|
// senza quota ma spostata: linea sottile dal punto originale alla scritta
|
||||||
|
var r2 = function (v) { return String(Math.round(v * 100) / 100); };
|
||||||
|
quote += '<line x1="' + r2(l.x) + '" y1="' + r2(l.y) + '" x2="' + r2(l.x + t[0]) + '" y2="' + r2(l.y + t[1]) +
|
||||||
|
'" stroke="' + col + '" stroke-width="1" vector-effect="non-scaling-stroke"/>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// #126: no-layers = disegno di prima dei livelli: tutte le forme nell'ordine dato, poi le quote; niente <g>, niente opacity
|
||||||
|
if (noLay) { ov.innerHTML = VO.svg(this._ov || [], { zoom: z }) + quote; return; }
|
||||||
|
var g = function (name, body, extra) {
|
||||||
|
return '<g data-layer="' + name + '"' + (extra || '') + (lay[name] ? '' : ' display="none"') + '>' + body + '</g>';
|
||||||
|
};
|
||||||
|
ov.innerHTML = g('ricerca', VO.svg(by.ricerca, { zoom: z })) +
|
||||||
|
g('costruzioni', VO.svg(by.costruzioni, { zoom: z }), ' opacity="0.5"') + g('quote', quote);
|
||||||
|
}
|
||||||
|
|
||||||
|
// #12: punto della scritta di un'etichetta in px logici: sulla quota, oppure (x,y) + testo [dx,dy], oppure (x,y)
|
||||||
|
_labelPos(l) {
|
||||||
|
var VO = typeof window !== 'undefined' ? window.VisionOverlay : null;
|
||||||
|
if (l.quota && VO && VO.quotaPunto) { var q = VO.quotaPunto(l.quota); if (q) return { x: q.x, y: q.y, quota: true }; }
|
||||||
|
var t = l.testo;
|
||||||
|
if (Array.isArray(t) && t.length === 2 && isFinite(t[0]) && isFinite(t[1])) return { x: l.x + t[0], y: l.y + t[1], quota: false };
|
||||||
|
return { x: l.x, y: l.y, quota: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// #122: etichette in px logici -> % dello stage, cosi' seguono lo zoom senza ingrandirsi
|
||||||
|
// #12: testo per «Nomi» (text) o solo valore (breve); il livello Quote nasconde tutte le etichette
|
||||||
|
_drawLabels() {
|
||||||
|
var box = this._lbEl;
|
||||||
|
while (box.firstChild) box.removeChild(box.firstChild);
|
||||||
|
var noLay = this._noLay(); // #126
|
||||||
|
box.style.display = noLay || this._lay.quote ? '' : 'none'; // #126: con no-layers le etichette sono sempre visibili
|
||||||
|
if (!this._w) return;
|
||||||
|
var self = this, nomi = noLay || this._lay.nomi; // #126: con no-layers testo = text
|
||||||
|
(this._lb || []).forEach(function (l) {
|
||||||
|
if (!l || !isFinite(l.x) || !isFinite(l.y)) return;
|
||||||
|
var d = document.createElement('div');
|
||||||
|
d.className = 'tmfv-label' + (l.cls ? ' ' + l.cls : '');
|
||||||
|
var p = self._labelPos(l);
|
||||||
|
if (l.key != null) d.setAttribute('data-key', l.key);
|
||||||
|
d.style.left = (p.x / self._w * 100) + '%';
|
||||||
|
d.style.top = (p.y / self._h * 100) + '%';
|
||||||
|
if (p.quota) d.style.transform = 'translate(-50%,-50%)'; // la scritta di una quota e' centrata sul suo punto
|
||||||
|
var txt = nomi ? l.text : (l.breve != null ? l.breve : l.text);
|
||||||
|
d.textContent = txt == null ? '' : String(txt);
|
||||||
|
if (l.key != null && !noLay) { // #126: con no-layers nessun trascinamento
|
||||||
|
d.addEventListener('pointerdown', function (ev) { self._labelDown(ev, l); });
|
||||||
|
d.addEventListener('dblclick', function (ev) { self._labelReset(ev, l); });
|
||||||
|
}
|
||||||
|
box.appendChild(d);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// #12: sposta la scritta dell'etichetta nel punto (x, y) in px logici (testo [du,dv] per le quote, [dx,dy] per le altre)
|
||||||
|
_labelSet(l, x, y) {
|
||||||
|
var VO = typeof window !== 'undefined' ? window.VisionOverlay : null;
|
||||||
|
var t = null;
|
||||||
|
if (l.quota && VO && VO.quotaTesto) t = VO.quotaTesto(l.quota, x, y);
|
||||||
|
if (t) l.quota = Object.assign({}, l.quota, { testo: t });
|
||||||
|
else t = [x - l.x, y - l.y];
|
||||||
|
l.testo = t;
|
||||||
|
}
|
||||||
|
|
||||||
|
_labelEmit(l, testo) {
|
||||||
|
this.dispatchEvent(new CustomEvent('quota-spostata', { bubbles: true,
|
||||||
|
detail: { key: l.key, nome: l.nome == null ? null : l.nome, camera: l.camera == null ? null : l.camera, testo: testo } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// #12: trascinamento di un'etichetta: niente pan (la propagazione si ferma), movimento sul documento
|
||||||
|
_labelDown(ev, l) {
|
||||||
|
if (ev.pointerType === 'mouse' && ev.button) return;
|
||||||
|
ev.stopPropagation(); ev.preventDefault();
|
||||||
|
var self = this, z = this._z || 1, p0 = this._labelPos(l);
|
||||||
|
var st = { cx: ev.clientX, cy: ev.clientY, moved: false };
|
||||||
|
var move = function (e) {
|
||||||
|
if (!st.moved && Math.abs(e.clientX - st.cx) + Math.abs(e.clientY - st.cy) < 3) return; // soglia: un clic non sposta
|
||||||
|
st.moved = true;
|
||||||
|
self._labelSet(l, p0.x + (e.clientX - st.cx) / z, p0.y + (e.clientY - st.cy) / z);
|
||||||
|
self._drawOverlay(); self._drawLabels();
|
||||||
|
};
|
||||||
|
var up = function () {
|
||||||
|
document.removeEventListener('pointermove', move);
|
||||||
|
document.removeEventListener('pointerup', up);
|
||||||
|
document.removeEventListener('pointercancel', up);
|
||||||
|
self._ldrag = null;
|
||||||
|
if (!st.moved) return;
|
||||||
|
var t = [Math.round(l.testo[0] * 10) / 10, Math.round(l.testo[1] * 10) / 10]; // 0.1 px, come il contratto
|
||||||
|
if (l.quota) l.quota = Object.assign({}, l.quota, { testo: t });
|
||||||
|
l.testo = t;
|
||||||
|
self._drawOverlay(); self._drawLabels();
|
||||||
|
self._labelEmit(l, t);
|
||||||
|
};
|
||||||
|
this._ldrag = st;
|
||||||
|
document.addEventListener('pointermove', move);
|
||||||
|
document.addEventListener('pointerup', up);
|
||||||
|
document.addEventListener('pointercancel', up);
|
||||||
|
}
|
||||||
|
|
||||||
|
// #12: doppio clic = la scritta torna alla posizione di default (testo null)
|
||||||
|
_labelReset(ev, l) {
|
||||||
|
ev.stopPropagation(); ev.preventDefault();
|
||||||
|
if (l.quota) l.quota = Object.assign({}, l.quota, { testo: null });
|
||||||
|
l.testo = null;
|
||||||
|
this._drawOverlay(); this._drawLabels();
|
||||||
|
this._labelEmit(l, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// #12: livelli. Stato in localStorage (try/catch: puo' essere bloccato), chiave per save-name
|
||||||
|
_layKey() {
|
||||||
|
var n = this.getAttribute('save-name');
|
||||||
|
return 'tmf-viewer-layers' + (n ? ':' + n : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
_layersLoad() {
|
||||||
|
try {
|
||||||
|
var raw = window.localStorage.getItem(this._layKey()), o = raw ? JSON.parse(raw) : null, lay = this._lay;
|
||||||
|
if (o && typeof o === 'object') LAYER_NAMES.forEach(function (k) { if (typeof o[k] === 'boolean') lay[k] = o[k]; });
|
||||||
|
} catch (e) { /* storage non disponibile: restano i default */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
_layersSave() {
|
||||||
|
try { window.localStorage.setItem(this._layKey(), JSON.stringify(this._lay)); } catch (e) { /* storage non disponibile */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// #12: menu visibile con immagine o contenuto e almeno una forma o un'etichetta; immagine nascosta ma stage invariato
|
||||||
|
_syncLayers() {
|
||||||
|
if (!this._built || !this._layBar) return;
|
||||||
|
var has = (this._ov && this._ov.length) || (this._lb && this._lb.length);
|
||||||
|
this._layBar.hidden = this.hasAttribute('no-layers') || !this._w || !has;
|
||||||
|
this._img.style.visibility = this._noLay() || this._lay.immagine ? '' : 'hidden'; // #126: con no-layers l'immagine resta visibile
|
||||||
|
var self = this;
|
||||||
|
LAYER_NAMES.forEach(function (k) { self._layBoxes[k].checked = !!self._lay[k]; });
|
||||||
|
}
|
||||||
|
|
||||||
|
get layers() { return Object.assign({}, this._lay); }
|
||||||
|
set layers(v) {
|
||||||
|
var changed = false, lay = this._lay;
|
||||||
|
if (v && typeof v === 'object') {
|
||||||
|
LAYER_NAMES.forEach(function (k) {
|
||||||
|
if (k in v && !!v[k] !== lay[k]) { lay[k] = !!v[k]; changed = true; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!changed || !this._layBar) return; // prima della costruzione del DOM resta solo lo stato
|
||||||
|
this._syncLayers(); this._drawOverlay(); this._drawLabels();
|
||||||
|
this._layersSave();
|
||||||
|
this.dispatchEvent(new CustomEvent('viewer-layers', { bubbles: true, detail: { layers: this.layers } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
get overlay() { return this._ov; }
|
||||||
|
set overlay(v) { this._ov = Array.isArray(v) ? v : []; if (this._built) { this._drawOverlay(); this._syncLayers(); } }
|
||||||
|
get labels() { return this._lb; }
|
||||||
|
set labels(v) { this._lb = Array.isArray(v) ? v : []; if (this._built) { this._drawOverlay(); this._drawLabels(); this._syncLayers(); } } // #12: le quote stanno nell'overlay
|
||||||
|
|
||||||
|
// #122: Salva = immagine grezza (src o save-src) o, in modo contenuto, l'SVG rasterizzato
|
||||||
|
_save() {
|
||||||
|
var name = this.getAttribute('save-name') || 'immagine';
|
||||||
|
if (this._hasSrc()) {
|
||||||
|
if (typeof tmfSaveImage === 'function') return tmfSaveImage(this.getAttribute('save-src') || this.getAttribute('src'), name);
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
|
if (!this._content || typeof tmfSvgToPngBlob !== 'function' || typeof tmfSaveBlob !== 'function') return Promise.resolve(false);
|
||||||
|
var self = this;
|
||||||
|
return tmfSvgToPngBlob(this._content, this._w, this._h).then(function (blob) {
|
||||||
|
return tmfSaveBlob(blob, name);
|
||||||
|
}).catch(function (err) {
|
||||||
|
if (typeof console !== 'undefined') console.error('tmf-viewer salva:', err);
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return TmfViewer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// customElements.define una sola volta, anche se lo script e' caricato due volte
|
||||||
|
if (typeof customElements !== 'undefined' && typeof HTMLElement !== 'undefined' &&
|
||||||
|
!(typeof customElements.get === 'function' && customElements.get(TAG))) {
|
||||||
|
customElements.define(TAG, defineViewer());
|
||||||
|
if (typeof document !== 'undefined') injectCss();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') window.TmfViewerMath = TmfViewerMath;
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = { TmfViewerMath: TmfViewerMath };
|
||||||
|
})();
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* save_image.js - #119: salva il frame grezzo mostrato nel pannello visione come PNG, senza disegni.
|
||||||
|
*
|
||||||
|
* Cosa fa: tmfSaveImage(src, baseName) scarica l'immagine `src` (blob: o http), la converte in PNG se serve
|
||||||
|
* e la salva con nome <baseName>_<AAAAMMGG-hhmmss>.png: «Salva con nome» (showSaveFilePicker)
|
||||||
|
* dove c'e', altrimenti <a download>. Ritorna una Promise<boolean> (false se annullato o fallito).
|
||||||
|
* tmfSaveImageName(baseName, date) e' la funzione pura del nome, esportata per i test.
|
||||||
|
* Come si usa: <script src=".../save_image.js"></script> poi @click="tmfSaveImage(url, 'task_14')".
|
||||||
|
* Dipende da: fetch, canvas, createImageBitmap e window del browser, toccati solo dentro le funzioni
|
||||||
|
* (il modulo si carica anche in node, con require, senza questi globali).
|
||||||
|
* #122: tmfSaveBlob(blob, baseName) salva un blob PNG gia' pronto; tmfSvgToPngBlob(svgEl, w, h) rasterizza un <svg>
|
||||||
|
* (sfondo bianco, lato lungo 1600-4096 px). Entrambi usati da <tmf-viewer> (image_viewer.js).
|
||||||
|
* Idea di conversione da image_to_png.js (#107).
|
||||||
|
*/
|
||||||
|
|
||||||
|
// #119: nome file proposto <baseName>_<AAAAMMGG-hhmmss>.png; baseName ripulito dai caratteri non validi
|
||||||
|
function tmfSaveImageName(baseName, date) {
|
||||||
|
var base = String(baseName === undefined || baseName === null ? '' : baseName)
|
||||||
|
.replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_') // caratteri vietati nei nomi file Windows
|
||||||
|
.replace(/\s+/g, '_')
|
||||||
|
.replace(/^[._]+|[._]+$/g, ''); // niente punti o underscore ai bordi
|
||||||
|
if (!base) base = 'immagine';
|
||||||
|
var d = date instanceof Date && !isNaN(date.getTime()) ? date : new Date();
|
||||||
|
var p = function (n) { return (n < 10 ? '0' : '') + n; };
|
||||||
|
var stamp = '' + d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + '-' +
|
||||||
|
p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds());
|
||||||
|
return base + '_' + stamp + '.png';
|
||||||
|
}
|
||||||
|
|
||||||
|
// #119: ridisegna su canvas un'immagine non PNG (es. JPEG, BMP) e ritorna il blob PNG
|
||||||
|
async function tmfBlobToPng(blob) {
|
||||||
|
var bitmap = await createImageBitmap(blob);
|
||||||
|
var canvas = document.createElement('canvas');
|
||||||
|
canvas.width = bitmap.width;
|
||||||
|
canvas.height = bitmap.height;
|
||||||
|
canvas.getContext('2d').drawImage(bitmap, 0, 0);
|
||||||
|
if (bitmap.close) bitmap.close();
|
||||||
|
var png = await new Promise(function (resolve) { canvas.toBlob(resolve, 'image/png'); });
|
||||||
|
if (!png) throw new Error('conversione PNG fallita');
|
||||||
|
return png;
|
||||||
|
}
|
||||||
|
|
||||||
|
// #122: «Salva con nome» (showSaveFilePicker) dove c'e'; null se manca (si ripiega su <a download>). Puo' lanciare AbortError
|
||||||
|
async function tmfPickPng(name) {
|
||||||
|
if (typeof window !== 'undefined' && typeof window.showSaveFilePicker === 'function') {
|
||||||
|
return window.showSaveFilePicker({
|
||||||
|
suggestedName: name,
|
||||||
|
types: [{ description: 'PNG', accept: { 'image/png': ['.png'] } }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// #122: scrive il blob sul file scelto (handle) oppure, senza handle, lo scarica con <a download>
|
||||||
|
async function tmfWritePng(handle, blob, name) {
|
||||||
|
if (handle) {
|
||||||
|
var w = await handle.createWritable();
|
||||||
|
await w.write(blob);
|
||||||
|
await w.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// #119: ripiego senza «Salva con nome» (browser non Chromium, contesto non sicuro): <a download>
|
||||||
|
var url = URL.createObjectURL(blob);
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = name;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// #122: gestione comune dell'errore di salvataggio: l'annullo dell'utente non e' un errore
|
||||||
|
function tmfSaveFailed(err, tag) {
|
||||||
|
if (err && err.name === 'AbortError') return false; // #119: annullato dall'utente, non e' un errore
|
||||||
|
if (typeof console !== 'undefined') console.error(tag + ':', err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// #122: salva un blob PNG gia' pronto (es. l'SVG del DXF rasterizzato) come <baseName>_<data>.png; true = salvato
|
||||||
|
async function tmfSaveBlob(blob, baseName) {
|
||||||
|
if (!blob) return false;
|
||||||
|
try {
|
||||||
|
var name = tmfSaveImageName(baseName, new Date());
|
||||||
|
var handle = await tmfPickPng(name);
|
||||||
|
await tmfWritePng(handle, blob, name);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
return tmfSaveFailed(err, 'tmfSaveBlob');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #122: rasterizza un <svg> in PNG (sfondo bianco). w,h = dimensioni logiche (default dal viewBox o dagli attributi);
|
||||||
|
// lato lungo portato fra 1600 e 4096 px. Ritorna una Promise<Blob> o lancia se il browser non riesce a disegnarlo.
|
||||||
|
async function tmfSvgToPngBlob(svgEl, w, h) {
|
||||||
|
var vb = svgEl.getAttribute && svgEl.getAttribute('viewBox');
|
||||||
|
var vbp = vb ? vb.trim().split(/[\s,]+/).map(Number) : [];
|
||||||
|
if (!(w > 0)) w = vbp.length === 4 ? vbp[2] : parseFloat(svgEl.getAttribute('width'));
|
||||||
|
if (!(h > 0)) h = vbp.length === 4 ? vbp[3] : parseFloat(svgEl.getAttribute('height'));
|
||||||
|
if (!(w > 0) || !(h > 0)) throw new Error('dimensioni SVG non valide');
|
||||||
|
var scale = Math.min(4096, Math.max(1600, Math.max(w, h))) / Math.max(w, h);
|
||||||
|
var cw = Math.max(1, Math.round(w * scale));
|
||||||
|
var ch = Math.max(1, Math.round(h * scale));
|
||||||
|
var clone = svgEl.cloneNode(true);
|
||||||
|
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||||
|
if (!vb) clone.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
|
||||||
|
clone.setAttribute('width', cw); // dimensione in pixel del PNG: l'SVG isolato non ha il CSS della pagina
|
||||||
|
clone.setAttribute('height', ch);
|
||||||
|
if (clone.style) { clone.style.width = ''; clone.style.height = ''; }
|
||||||
|
var xml = new XMLSerializer().serializeToString(clone);
|
||||||
|
var url = URL.createObjectURL(new Blob([xml], { type: 'image/svg+xml;charset=utf-8' }));
|
||||||
|
try {
|
||||||
|
var img = new Image();
|
||||||
|
await new Promise(function (resolve, reject) {
|
||||||
|
img.onload = resolve;
|
||||||
|
img.onerror = function () { reject(new Error('SVG non disegnabile')); };
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
var canvas = document.createElement('canvas');
|
||||||
|
canvas.width = cw;
|
||||||
|
canvas.height = ch;
|
||||||
|
var g = canvas.getContext('2d');
|
||||||
|
g.fillStyle = '#ffffff';
|
||||||
|
g.fillRect(0, 0, cw, ch);
|
||||||
|
g.drawImage(img, 0, 0, cw, ch);
|
||||||
|
var png = await new Promise(function (resolve) { canvas.toBlob(resolve, 'image/png'); });
|
||||||
|
if (!png) throw new Error('conversione PNG fallita');
|
||||||
|
return png;
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #119: scarica `src`, lo porta a PNG e lo salva; true = salvato, false = annullato dall'utente o errore
|
||||||
|
async function tmfSaveImage(src, baseName) {
|
||||||
|
if (!src) return false; // #119: nessuna immagine mostrata
|
||||||
|
try {
|
||||||
|
var name = tmfSaveImageName(baseName, new Date());
|
||||||
|
// #119: «Salva con nome» per primo, finche' vale il gesto dell'utente (Chrome lo rifiuta dopo ~5 s di fetch e conversione)
|
||||||
|
var handle = await tmfPickPng(name);
|
||||||
|
var resp = await fetch(src);
|
||||||
|
if (!resp.ok) return false;
|
||||||
|
var blob = await resp.blob();
|
||||||
|
if (blob.type !== 'image/png') blob = await tmfBlobToPng(blob);
|
||||||
|
await tmfWritePng(handle, blob, name);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
return tmfSaveFailed(err, 'tmfSaveImage');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #119: globale nel browser, export per il test node
|
||||||
|
// #122: esportati anche tmfSaveBlob e tmfSvgToPngBlob (usati da <tmf-viewer>)
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.tmfSaveImage = tmfSaveImage; window.tmfSaveImageName = tmfSaveImageName;
|
||||||
|
window.tmfSaveBlob = tmfSaveBlob; window.tmfSvgToPngBlob = tmfSvgToPngBlob;
|
||||||
|
}
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = { tmfSaveImage, tmfSaveImageName, tmfSaveBlob, tmfSvgToPngBlob };
|
||||||
@@ -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';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// «Acquisizione» dell'editor task (#97): camera (servizio + sorgente) e modo di analisi
|
||||||
|
// («impulsiva» / «in linea») dei task camera_measure e dxf_compare.
|
||||||
|
// Legge /maker/api/camera/servizi e /maker/api/camera/sorgenti?url=. Lo stato scelto sta
|
||||||
|
// nel componente padre (editTaskData.acquisition): questo componente carica solo gli elenchi.
|
||||||
|
// Uso: x-data="taskAcquisition(() => editTaskData.acquisition, msgs)". Dipende da Alpine.js.
|
||||||
|
function taskAcquisition(getAcq, msgs) {
|
||||||
|
return {
|
||||||
|
msgs: msgs || {},
|
||||||
|
servizi: [],
|
||||||
|
sources: [],
|
||||||
|
loading: false,
|
||||||
|
error: '',
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
await this.loadServizi();
|
||||||
|
// Task con camera gia' scelta: elenco sorgenti del suo servizio
|
||||||
|
const acq = getAcq();
|
||||||
|
if (acq && acq.camera_url) await this.loadSources(acq.camera_url);
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadServizi() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/maker/api/camera/servizi');
|
||||||
|
if (resp.ok) this.servizi = (await resp.json()).servizi || [];
|
||||||
|
} catch (e) { console.error('taskAcquisition servizi', e); }
|
||||||
|
},
|
||||||
|
|
||||||
|
// Servizio salvato nel task ma non piu' fra quelli delle stazioni: resta selezionabile
|
||||||
|
serviziConCorrente() {
|
||||||
|
const acq = getAcq();
|
||||||
|
const out = this.servizi.slice();
|
||||||
|
if (acq && acq.camera_url && !out.some(s => s.camera_url === acq.camera_url)) {
|
||||||
|
out.push({ camera_url: acq.camera_url, stazioni: [] });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Cambio servizio: la sorgente scelta non vale piu'
|
||||||
|
async onServizio() {
|
||||||
|
const acq = getAcq();
|
||||||
|
acq.camera_source = '';
|
||||||
|
this.sources = [];
|
||||||
|
this.error = '';
|
||||||
|
if (acq.camera_url) await this.loadSources(acq.camera_url);
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadSources(url) {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/maker/api/camera/sorgenti?url=' + encodeURIComponent(url));
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
if (resp.ok) this.sources = data.sources || [];
|
||||||
|
else this.error = data.detail || this.msgs.genericError || '';
|
||||||
|
} catch (e) {
|
||||||
|
this.error = this.msgs.genericError || String(e);
|
||||||
|
} finally { this.loading = false; }
|
||||||
|
},
|
||||||
|
|
||||||
|
// Sorgente salvata ma non piu' elencata dal servizio: resta visibile
|
||||||
|
sourcesConCorrente() {
|
||||||
|
const acq = getAcq();
|
||||||
|
const out = this.sources.slice();
|
||||||
|
if (acq && acq.camera_source && !out.some(s => s.name === acq.camera_source)) {
|
||||||
|
out.push({ name: acq.camera_source, label: acq.camera_source });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -11,18 +11,152 @@
|
|||||||
* taskType, subtasks, labels.
|
* taskType, subtasks, labels.
|
||||||
* Run context (lot, serial, production run) is read lazily through `ctx()`,
|
* Run context (lot, serial, production run) is read lazily through `ctx()`,
|
||||||
* which the template binds to the parent task component.
|
* which the template binds to the parent task component.
|
||||||
|
*
|
||||||
|
* #97: with a camera (cameraAvailable, the task's or the station's) the task opens
|
||||||
|
* on a frame already grabbed and analysed (/vision/check, nothing saved: «preview»).
|
||||||
|
* modo «impulsiva»: «Acquisisci» redoes grab+check. modo «in_linea»: continuous
|
||||||
|
* loop grab+check, one request at a time, standing still while paused / page hidden
|
||||||
|
* / leaving / saving. «Registra» saves the LAST frame shown through /vision/execute.
|
||||||
|
* Extra config: cameraAvailable, modo, checkUrl.
|
||||||
*/
|
*/
|
||||||
function visionExecute() {
|
function visionExecute() {
|
||||||
// Overlay colour per role (VisionSuite roles: reference, roi, found).
|
|
||||||
const ROLE_COLORS = { reference: '#2563eb', found: '#16a34a', roi: '#f59e0b' };
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
busy: false,
|
busy: false,
|
||||||
|
grabbing: false, // frame requested from the station camera (#72)
|
||||||
error: '',
|
error: '',
|
||||||
result: null, // last execute response
|
result: null, // last execute response
|
||||||
imageSrc: '', // object URL of the acquired image (shown under the overlay)
|
imageSrc: '', // object URL of the acquired image (shown under the overlay)
|
||||||
ctx: () => ({}), // replaced by the template: {lot, serial, runId}
|
ctx: () => ({}), // replaced by the template: {lot, serial, runId}
|
||||||
|
|
||||||
|
// #97: preview / loop state
|
||||||
|
preview: false, // result on screen is an analysis only, not saved
|
||||||
|
saved: false, // «Registra» done: the saved result replaces the preview
|
||||||
|
lastBlob: null, // last frame shown, the one «Registra» saves
|
||||||
|
recordedBlob: null, // #123: frame saved by the last successful «Registra»: not recordable twice
|
||||||
|
paused: false, // operator paused the in-line loop
|
||||||
|
registering: false, // «Registra» in progress: the loop stands still
|
||||||
|
leaving: false, // page is being left: no more requests
|
||||||
|
looping: false, // an in-line loop is running
|
||||||
|
gen: 0, // bumped by «Registra»: answers of older requests are dropped
|
||||||
|
|
||||||
|
// #97: start on a frame already acquired and analysed; hook visibility and exit.
|
||||||
|
init() {
|
||||||
|
// #120: «Rimisura»/nuovo ciclo (task-result-reset): il pezzo va registrato di nuovo, anche senza camera
|
||||||
|
window.addEventListener('task-result-reset', () => {
|
||||||
|
this.saved = false;
|
||||||
|
// #123: il frame gia' registrato non e' piu' un'anteprima ne' si registra due volte
|
||||||
|
this.preview = !!this.result && this.lastBlob !== this.recordedBlob;
|
||||||
|
// #123: camera impulsiva = frame nuovo subito (come all'apertura); in linea riparte il ciclo
|
||||||
|
if (this.hasCamera && !this.inLinea) this.grab(); else this.kick();
|
||||||
|
});
|
||||||
|
if (!this.hasCamera) return;
|
||||||
|
document.addEventListener('visibilitychange', () => { if (!document.hidden) this.kick(); });
|
||||||
|
const leave = () => { this.leaving = true; };
|
||||||
|
window.addEventListener('pagehide', leave);
|
||||||
|
window.addEventListener('beforeunload', leave);
|
||||||
|
// After x-init has bound ctx(): in-line starts the loop, impulsive grabs once.
|
||||||
|
this.$nextTick(() => { if (this.inLinea) this.loop(); else this.grab(); });
|
||||||
|
},
|
||||||
|
|
||||||
|
get hasCamera() { return !!(window.__visionCfg || {}).cameraAvailable; },
|
||||||
|
get inLinea() { return this.hasCamera && (window.__visionCfg || {}).modo === 'in_linea'; },
|
||||||
|
// «Registra» needs a frame on screen that is not saved yet
|
||||||
|
get canRecord() { return !!this.lastBlob && this.lastBlob !== this.recordedBlob && !this.saved && !this.registering; }, // #123: non sul frame gia' registrato
|
||||||
|
|
||||||
|
shouldRun() {
|
||||||
|
return this.inLinea && !this.paused && !this.saved && !this.registering
|
||||||
|
&& !this.leaving && !document.hidden;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Page visible again: restart the loop unless the operator paused it.
|
||||||
|
kick() { if (this.shouldRun() && !this.looping) this.loop(); },
|
||||||
|
|
||||||
|
// In-line loop: one request at a time, at least 300 ms between two of them
|
||||||
|
// (longer after an error, so a dead camera is not hammered).
|
||||||
|
async loop() {
|
||||||
|
if (this.looping) return;
|
||||||
|
this.looping = true;
|
||||||
|
try {
|
||||||
|
while (this.shouldRun()) {
|
||||||
|
const ok = await this.grab();
|
||||||
|
await new Promise((r) => setTimeout(r, ok ? 300 : 2000));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.looping = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// «Pausa» / «Riprendi»; after «Registra» only «Riprendi» restarts the loop.
|
||||||
|
togglePause() {
|
||||||
|
if (this.paused || this.saved) {
|
||||||
|
this.paused = false;
|
||||||
|
this.saved = false;
|
||||||
|
this.loop();
|
||||||
|
} else {
|
||||||
|
this.paused = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Analysis only (/vision/check): the frame becomes the preview, nothing is saved.
|
||||||
|
async analyze(blob) {
|
||||||
|
const cfg = window.__visionCfg || {};
|
||||||
|
if (!blob || !cfg.checkUrl) return false;
|
||||||
|
const gen = this.gen;
|
||||||
|
this.busy = true;
|
||||||
|
this.error = '';
|
||||||
|
try {
|
||||||
|
const c = this.ctx() || {};
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('task_id', cfg.taskId);
|
||||||
|
fd.append('image', blob, blob.name || 'capture.jpg');
|
||||||
|
if (c.runId) fd.append('production_run_id', c.runId);
|
||||||
|
const csrf = document.querySelector('meta[name=csrf-token]')?.content || '';
|
||||||
|
const resp = await fetch(cfg.checkUrl, {
|
||||||
|
method: 'POST', headers: { 'X-CSRFToken': csrf }, body: fd,
|
||||||
|
});
|
||||||
|
const body = await resp.json().catch(() => ({}));
|
||||||
|
if (gen !== this.gen) return false; // «Registra» took over
|
||||||
|
if (!resp.ok || body.error) {
|
||||||
|
this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (this.imageSrc) URL.revokeObjectURL(this.imageSrc);
|
||||||
|
this.imageSrc = URL.createObjectURL(blob);
|
||||||
|
this.lastBlob = blob;
|
||||||
|
this.result = body;
|
||||||
|
this.preview = true;
|
||||||
|
this.saved = false;
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
if (gen === this.gen) this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
if (gen === this.gen) this.busy = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// «Registra»: save the LAST frame shown with the usual /vision/execute.
|
||||||
|
async record() {
|
||||||
|
if (!this.canRecord) return;
|
||||||
|
const blob = this.lastBlob;
|
||||||
|
this.registering = true;
|
||||||
|
this.gen++; // drop whatever request is still in flight
|
||||||
|
this.busy = false;
|
||||||
|
this.grabbing = false;
|
||||||
|
try {
|
||||||
|
await this.acquire(blob);
|
||||||
|
if (!this.error && this.result && this.result.vision_result_id) {
|
||||||
|
this.preview = false;
|
||||||
|
this.saved = true;
|
||||||
|
this.recordedBlob = blob; // #123: questo frame e' registrato, Registra resta spento finche' non ne arriva uno nuovo
|
||||||
|
this._emitRecorded(); // #120: la pagina conta le quote registrate
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.registering = false;
|
||||||
|
this.kick(); // in-line: stays still while saved, restarts if the save failed
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Entry point shared with the future agent camera.
|
// Entry point shared with the future agent camera.
|
||||||
async acquire(blob) {
|
async acquire(blob) {
|
||||||
if (!blob || this.busy) return;
|
if (!blob || this.busy) return;
|
||||||
@@ -52,8 +186,8 @@ function visionExecute() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.result = body;
|
this.result = body;
|
||||||
// Draw once Alpine has rendered the canvas.
|
// #120: senza camera il file scelto e' salvato subito (nessun «Registra»): stesso evento
|
||||||
this.$nextTick(() => this.drawOverlay());
|
if (!this.hasCamera && body.vision_result_id) this._emitRecorded();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
||||||
} finally {
|
} finally {
|
||||||
@@ -61,11 +195,60 @@ function visionExecute() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// #120: misure salvate (risposta di execute) alla pagina: conteggio quote e «Completato»
|
||||||
|
_emitRecorded() {
|
||||||
|
const ms = (this.result && this.result.measurements) || [];
|
||||||
|
window.dispatchEvent(new CustomEvent('vision-recorded', { detail: { measurements: ms } }));
|
||||||
|
},
|
||||||
|
|
||||||
|
// Station camera (#72): the Flask proxy answers a PNG, which goes through
|
||||||
|
// acquire() like a picked file. Errors show as the execute ones do.
|
||||||
|
// #97: sends task_id (the task's camera wins over the station's); with a camera
|
||||||
|
// the frame goes to the preview analysis. Returns true when the frame was analysed.
|
||||||
|
async grab() {
|
||||||
|
const cfg = window.__visionCfg || {};
|
||||||
|
if (this.busy || this.grabbing || this.registering || !cfg.grabUrl) return false;
|
||||||
|
const gen = this.gen;
|
||||||
|
this.grabbing = true;
|
||||||
|
this.error = '';
|
||||||
|
let blob = null;
|
||||||
|
try {
|
||||||
|
const csrf = document.querySelector('meta[name=csrf-token]')?.content || '';
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('task_id', cfg.taskId);
|
||||||
|
const resp = await fetch(cfg.grabUrl, {
|
||||||
|
method: 'POST', headers: { 'X-CSRFToken': csrf }, body: fd,
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const body = await resp.json().catch(() => ({}));
|
||||||
|
if (gen === this.gen) this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
blob = await resp.blob();
|
||||||
|
} catch (e) {
|
||||||
|
if (gen === this.gen) this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
if (gen === this.gen) this.grabbing = false;
|
||||||
|
}
|
||||||
|
if (!blob || gen !== this.gen) return false;
|
||||||
|
if (this.hasCamera) return await this.analyze(blob);
|
||||||
|
await this.acquire(blob);
|
||||||
|
return !this.error;
|
||||||
|
},
|
||||||
|
|
||||||
// Input change: take the file and allow picking the same one again.
|
// Input change: take the file and allow picking the same one again.
|
||||||
|
// #97: with a camera the file is a preview too (the loop pauses), «Registra» saves it.
|
||||||
onPick(ev) {
|
onPick(ev) {
|
||||||
const f = ev.target.files && ev.target.files[0];
|
const f = ev.target.files && ev.target.files[0];
|
||||||
ev.target.value = '';
|
ev.target.value = '';
|
||||||
if (f) this.acquire(f);
|
if (!f) return;
|
||||||
|
if (this.hasCamera) {
|
||||||
|
if (this.inLinea) this.paused = true;
|
||||||
|
this.analyze(f);
|
||||||
|
} else {
|
||||||
|
this.acquire(f);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Image shown: the saved one from the server when available, else the local one.
|
// Image shown: the saved one from the server when available, else the local one.
|
||||||
@@ -84,14 +267,51 @@ function visionExecute() {
|
|||||||
return ((this.result && this.result.measurements) || []).map((m) => {
|
return ((this.result && this.result.measurements) || []).map((m) => {
|
||||||
const st = subs.find((s) => s.id === m.subtask_id) || {};
|
const st = subs.find((s) => s.id === m.subtask_id) || {};
|
||||||
return {
|
return {
|
||||||
label: (st.marker_number ? st.marker_number + '. ' : '') + (st.measurement_type || st.name || '#' + m.subtask_id),
|
// #116: chiave stabile della quota, nome senza numero di marker e uscita ricetta, per le etichette sull'immagine
|
||||||
|
key: m.subtask_id,
|
||||||
|
name: st.description || st.measurement_type || '#' + m.subtask_id,
|
||||||
|
vision_output: st.vision_output,
|
||||||
|
// #114: il nome della quota e' description (prima: measurement_type/name, che per le quote DXF e' vuoto -> '#15')
|
||||||
|
label: (st.marker_number ? st.marker_number + '. ' : '') + (st.description || st.measurement_type || '#' + m.subtask_id),
|
||||||
value: m.value,
|
value: m.value,
|
||||||
unit: st.unit || 'mm',
|
unit: st.unit || 'mm',
|
||||||
status: m.pass_fail,
|
status: m.pass_fail,
|
||||||
|
// #117: nominale e tolleranze della quota (come la schermata dei calibri); null se mancano
|
||||||
|
nominal: st.nominal, ltl: st.ltl, lwl: st.lwl, uwl: st.uwl, utl: st.utl,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// #122: zoom, pan, resize e Salva li gestisce <tmf-viewer> (static/js/image_viewer.js)
|
||||||
|
|
||||||
|
// #118: valore della cella: una quota non misurata (null) e' «NaN», mai Number(null) = 0
|
||||||
|
fmtValue(v, unit) {
|
||||||
|
return (v == null || !Number.isFinite(Number(v)) ? 'NaN' : Number(v).toFixed(3)) + ' ' + unit;
|
||||||
|
},
|
||||||
|
|
||||||
|
// #117: tolleranza a 3 decimali, trattino se manca (come x-text="...?.toFixed(3) || '—'" dei calibri, ma 0 resta 0.000)
|
||||||
|
fmtTol(v) { return v == null ? '\u2014' : Number(v).toFixed(3); },
|
||||||
|
|
||||||
|
// #119 #122: nome proposto da «Salva» (titolo del task) per <tmf-viewer save-name>
|
||||||
|
get saveName() { return (window.__visionCfg || {}).taskName || 'immagine'; },
|
||||||
|
|
||||||
|
// #116 #122: etichette «nome valore unita» per <tmf-viewer>: x/y in px logici dell'immagine
|
||||||
|
// (il componente le posiziona in % e le tiene a dimensione costante a ogni zoom)
|
||||||
|
get viewerLabels() {
|
||||||
|
if (typeof VisionOverlay === 'undefined' || !this.result) return [];
|
||||||
|
return VisionOverlay.labels(this.rows, this.result.overlay || []).map((l) => ({
|
||||||
|
key: l.key, x: l.x, y: l.y, text: l.text, cls: this.labelClass(l.status),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
// #116: colori dell'etichetta per esito
|
||||||
|
labelClass(s) {
|
||||||
|
if (s === 'pass') return 'bg-green-600 text-white';
|
||||||
|
if (s === 'warning') return 'bg-amber-400 text-black';
|
||||||
|
if (s === 'fail') return 'bg-red-600 text-white';
|
||||||
|
return 'bg-gray-700 text-white';
|
||||||
|
},
|
||||||
|
|
||||||
statusClass(s) {
|
statusClass(s) {
|
||||||
// Same palette as the measurement markers of the manual flow.
|
// Same palette as the measurement markers of the manual flow.
|
||||||
if (s === 'pass') return 'bg-measure-pass/20 text-measure-pass border border-measure-pass/40';
|
if (s === 'pass') return 'bg-measure-pass/20 text-measure-pass border border-measure-pass/40';
|
||||||
@@ -99,27 +319,5 @@ function visionExecute() {
|
|||||||
if (s === 'warning') return 'bg-amber-100 dark:bg-amber-900/30 text-measure-warning border border-measure-warning/40';
|
if (s === 'warning') return 'bg-amber-100 dark:bg-amber-900/30 text-measure-warning border border-measure-warning/40';
|
||||||
return 'bg-[var(--bg-secondary)] text-[var(--text-muted)] border border-[var(--border-color)]';
|
return 'bg-[var(--bg-secondary)] text-[var(--text-muted)] border border-[var(--border-color)]';
|
||||||
},
|
},
|
||||||
|
|
||||||
// Canvas is sized to the natural image, so overlay coordinates (image px)
|
|
||||||
// are used as they are and CSS scales the whole thing to the displayed size.
|
|
||||||
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';
|
|
||||||
for (const item of ((this.result && this.result.overlay) || [])) {
|
|
||||||
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,424 @@
|
|||||||
|
/**
|
||||||
|
* #116: disegno dell'overlay del check visione sul canvas del pannello operatore e
|
||||||
|
* ancoraggio delle etichette delle quote alla geometria del loro strumento.
|
||||||
|
*
|
||||||
|
* Script classico (niente import/export): lo carica task_execute.html prima di
|
||||||
|
* vision_execute.js, che lo usa via window.VisionOverlay. In node (test) si carica
|
||||||
|
* con require.
|
||||||
|
*
|
||||||
|
* API: draw(g, overlay, lw) -> {drawn, skipped}; anchor(item) -> {x, y} | null;
|
||||||
|
* labels(rows, overlay) -> [{key, x, y, text, status}];
|
||||||
|
* svg(overlay, opts) -> stringa di figli SVG (#121, vedi sotto);
|
||||||
|
* layerOf(o), quotaPunto(q), quotaTesto(q, x, y), quotaSvg(q, opts): livelli e quote CAD (#12).
|
||||||
|
* Convenzioni (vs-tools): row = y, col = x; angoli in gradi positivi antiorari a video.
|
||||||
|
* Porting da TieVision-Studio frontend/src/comune/overlay.js (verticiRect2, forma)
|
||||||
|
* e frontend/src/editor/misure.js (ancoraOverlay).
|
||||||
|
*/
|
||||||
|
var VisionOverlay = (function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// #116: colore per ruolo, uguale a quello dello Studio
|
||||||
|
var COLORS = { reference: '#2563eb', found: '#16a34a', roi: '#f59e0b' };
|
||||||
|
var COLOR_OTHER = '#ef4444';
|
||||||
|
|
||||||
|
function rad(d) { return (d * Math.PI) / 180; }
|
||||||
|
function num(v) { return typeof v === 'number' && isFinite(v); }
|
||||||
|
function allNum(o) {
|
||||||
|
for (var i = 1; i < arguments.length; i++) {
|
||||||
|
if (!o || !num(o[arguments[i]])) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// #116: serie rows/cols valida = entrambe array, stessa lunghezza, non vuote, tutti numeri finiti
|
||||||
|
function validSeries(o) {
|
||||||
|
if (!o || !Array.isArray(o.rows) || !Array.isArray(o.cols)) return false;
|
||||||
|
if (o.rows.length === 0 || o.rows.length !== o.cols.length) return false;
|
||||||
|
for (var i = 0; i < o.rows.length; i++) {
|
||||||
|
if (!num(o.rows[i]) || !num(o.cols[i])) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// #116: i 4 vertici del rect2 (x = col, y = row), come verticiRect2 dello Studio
|
||||||
|
function rect2Vertices(o) {
|
||||||
|
var a = rad(o.phi_deg);
|
||||||
|
var u = [Math.cos(a), -Math.sin(a)];
|
||||||
|
var v = [-Math.sin(a), -Math.cos(a)]; // u ruotato di +90 antiorario
|
||||||
|
return [[1, 1], [1, -1], [-1, -1], [-1, 1]].map(function (st) {
|
||||||
|
return [
|
||||||
|
o.col + st[0] * o.length1 * u[0] + st[1] * o.length2 * v[0],
|
||||||
|
o.row + st[0] * o.length1 * u[1] + st[1] * o.length2 * v[1],
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// #116: disegna una forma; true se disegnata, false se saltata (tipo ignoto o dati malformati)
|
||||||
|
function drawShape(g, o, lw) {
|
||||||
|
var type = o && o.type;
|
||||||
|
var i;
|
||||||
|
switch (type) {
|
||||||
|
case 'rect2': {
|
||||||
|
if (!allNum(o, 'row', 'col', 'phi_deg', 'length1', 'length2')) return false;
|
||||||
|
var vs = rect2Vertices(o);
|
||||||
|
g.beginPath();
|
||||||
|
g.moveTo(vs[0][0], vs[0][1]);
|
||||||
|
for (i = 1; i < vs.length; i++) g.lineTo(vs[i][0], vs[i][1]);
|
||||||
|
g.closePath();
|
||||||
|
g.stroke();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case 'segment':
|
||||||
|
case 'line':
|
||||||
|
if (!allNum(o, 'row1', 'col1', 'row2', 'col2')) return false;
|
||||||
|
g.beginPath();
|
||||||
|
g.moveTo(o.col1, o.row1);
|
||||||
|
g.lineTo(o.col2, o.row2);
|
||||||
|
g.stroke();
|
||||||
|
return true;
|
||||||
|
case 'circle':
|
||||||
|
if (!allNum(o, 'row', 'col', 'radius')) return false;
|
||||||
|
g.beginPath();
|
||||||
|
g.arc(o.col, o.row, o.radius, 0, 2 * Math.PI);
|
||||||
|
g.stroke();
|
||||||
|
return true;
|
||||||
|
case 'arc':
|
||||||
|
if (!allNum(o, 'row', 'col', 'radius', 'start_deg', 'extent_deg')) return false;
|
||||||
|
g.beginPath();
|
||||||
|
// #116: y canvas verso il basso, quindi angoli negati; extent > 0 = antiorario a video
|
||||||
|
g.arc(o.col, o.row, o.radius, -rad(o.start_deg), -rad(o.start_deg + o.extent_deg), o.extent_deg > 0);
|
||||||
|
g.stroke();
|
||||||
|
return true;
|
||||||
|
case 'points': {
|
||||||
|
if (!validSeries(o)) return false;
|
||||||
|
var m = Math.max(3, lw * 2); // semi-lato della croce
|
||||||
|
g.beginPath();
|
||||||
|
for (i = 0; i < o.rows.length; i++) {
|
||||||
|
g.moveTo(o.cols[i] - m, o.rows[i]);
|
||||||
|
g.lineTo(o.cols[i] + m, o.rows[i]);
|
||||||
|
g.moveTo(o.cols[i], o.rows[i] - m);
|
||||||
|
g.lineTo(o.cols[i], o.rows[i] + m);
|
||||||
|
}
|
||||||
|
g.stroke();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case 'contour':
|
||||||
|
if (!validSeries(o)) return false;
|
||||||
|
g.beginPath();
|
||||||
|
g.moveTo(o.cols[0], o.rows[0]);
|
||||||
|
for (i = 1; i < o.rows.length; i++) g.lineTo(o.cols[i], o.rows[i]);
|
||||||
|
g.stroke(); // polilinea aperta, come nello Studio
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw(g, overlay, lw) {
|
||||||
|
var res = { drawn: 0, skipped: 0 };
|
||||||
|
var items = Array.isArray(overlay) ? overlay : [];
|
||||||
|
for (var k = 0; k < items.length; k++) {
|
||||||
|
var o = items[k];
|
||||||
|
var role = o && o.role;
|
||||||
|
var isRoi = role === 'roi';
|
||||||
|
g.strokeStyle = COLORS[role] || COLOR_OTHER;
|
||||||
|
g.lineWidth = isRoi ? lw * 0.75 : lw;
|
||||||
|
g.setLineDash(isRoi ? [lw * 3, lw * 2] : []); // roi tratteggiata
|
||||||
|
var ok = false;
|
||||||
|
try { ok = drawShape(g, o, lw); } catch (e) { ok = false; } // un elemento rotto non ferma gli altri
|
||||||
|
if (ok) res.drawn++; else res.skipped++;
|
||||||
|
}
|
||||||
|
g.setLineDash([]);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// #121: numero per il markup SVG, al piu' 2 decimali (niente stringhe dei dati nel markup)
|
||||||
|
function n2(v) { return String(Math.round(v * 100) / 100); }
|
||||||
|
function pos(v, dflt) { return num(v) && v > 0 ? v : dflt; }
|
||||||
|
|
||||||
|
// #121: path SVG di un arco, porting di percorsoArco dello Studio; extent > 0 = antiorario a video
|
||||||
|
function arcPath(o) {
|
||||||
|
var ext = Math.max(-359.99, Math.min(359.99, o.extent_deg)); // 360 pieni darebbero start == end
|
||||||
|
var a0 = rad(o.start_deg);
|
||||||
|
var a1 = rad(o.start_deg + ext);
|
||||||
|
var x0 = o.col + o.radius * Math.cos(a0), y0 = o.row - o.radius * Math.sin(a0);
|
||||||
|
var x1 = o.col + o.radius * Math.cos(a1), y1 = o.row - o.radius * Math.sin(a1);
|
||||||
|
var large = Math.abs(ext) > 180 ? 1 : 0;
|
||||||
|
var sweep = ext > 0 ? 0 : 1; // y verso il basso: antiorario a video = sweep-flag 0
|
||||||
|
return 'M ' + n2(x0) + ' ' + n2(y0) + ' A ' + n2(o.radius) + ' ' + n2(o.radius) +
|
||||||
|
' 0 ' + large + ' ' + sweep + ' ' + n2(x1) + ' ' + n2(y1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// #121: [tag, attributi geometrici] di una forma, o null se tipo ignoto o dati malformati (stessi criteri di drawShape)
|
||||||
|
function shapeSvg(o, cross) {
|
||||||
|
var i, pts;
|
||||||
|
switch (o && o.type) {
|
||||||
|
case 'rect2':
|
||||||
|
if (!allNum(o, 'row', 'col', 'phi_deg', 'length1', 'length2')) return null;
|
||||||
|
pts = rect2Vertices(o).map(function (v) { return n2(v[0]) + ',' + n2(v[1]); }).join(' ');
|
||||||
|
return ['polygon', 'points="' + pts + '"'];
|
||||||
|
case 'segment':
|
||||||
|
case 'line':
|
||||||
|
if (!allNum(o, 'row1', 'col1', 'row2', 'col2')) return null;
|
||||||
|
return ['line', 'x1="' + n2(o.col1) + '" y1="' + n2(o.row1) + '" x2="' + n2(o.col2) + '" y2="' + n2(o.row2) + '"'];
|
||||||
|
case 'circle':
|
||||||
|
if (!allNum(o, 'row', 'col', 'radius')) return null;
|
||||||
|
return ['circle', 'cx="' + n2(o.col) + '" cy="' + n2(o.row) + '" r="' + n2(o.radius) + '"'];
|
||||||
|
case 'arc':
|
||||||
|
if (!allNum(o, 'row', 'col', 'radius', 'start_deg', 'extent_deg')) return null;
|
||||||
|
return ['path', 'd="' + arcPath(o) + '"'];
|
||||||
|
case 'points': {
|
||||||
|
if (!validSeries(o)) return null;
|
||||||
|
var d = [];
|
||||||
|
for (i = 0; i < o.rows.length; i++) {
|
||||||
|
var x = o.cols[i], y = o.rows[i];
|
||||||
|
d.push('M ' + n2(x - cross) + ' ' + n2(y) + ' L ' + n2(x + cross) + ' ' + n2(y) +
|
||||||
|
' M ' + n2(x) + ' ' + n2(y - cross) + ' L ' + n2(x) + ' ' + n2(y + cross));
|
||||||
|
}
|
||||||
|
return ['path', 'd="' + d.join(' ') + '"'];
|
||||||
|
}
|
||||||
|
case 'contour':
|
||||||
|
if (!validSeries(o)) return null;
|
||||||
|
pts = o.rows.map(function (r, k) { return n2(o.cols[k]) + ',' + n2(r); }).join(' ');
|
||||||
|
return ['polyline', 'points="' + pts + '"']; // aperta, come draw
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #121: overlay -> figli SVG (senza <svg> esterno) in px immagine. Tratto a spessore costante a video
|
||||||
|
// (vector-effect non-scaling-stroke), croci dei points a semi-braccio costante a video (cross / zoom).
|
||||||
|
function svg(overlay, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
var zoom = pos(opts.zoom, 1), lw = pos(opts.lw, 2), cross = pos(opts.cross, 6);
|
||||||
|
var items = Array.isArray(overlay) ? overlay : [];
|
||||||
|
var out = [];
|
||||||
|
for (var k = 0; k < items.length; k++) {
|
||||||
|
var o = items[k];
|
||||||
|
var sh = null;
|
||||||
|
try { sh = shapeSvg(o, cross / zoom); } catch (e) { sh = null; } // un elemento rotto non ferma gli altri
|
||||||
|
if (!sh) continue;
|
||||||
|
var role = o.role;
|
||||||
|
var isRoi = role === 'roi';
|
||||||
|
// solo colori fissi e numeri: nessuna stringa dei dati finisce nel markup
|
||||||
|
out.push('<' + sh[0] + ' ' + sh[1] + ' fill="none" stroke="' + (COLORS[role] || COLOR_OTHER) +
|
||||||
|
'" stroke-width="' + n2(isRoi ? lw * 0.75 : lw) + '"' +
|
||||||
|
(isRoi ? ' stroke-dasharray="6 4"' : '') + ' vector-effect="non-scaling-stroke"/>');
|
||||||
|
}
|
||||||
|
return out.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// #116: punto di ancoraggio dell'etichetta (px immagine), come ancoraOverlay dello Studio
|
||||||
|
function anchor(o) {
|
||||||
|
switch (o && o.type) {
|
||||||
|
case 'segment':
|
||||||
|
case 'line':
|
||||||
|
return allNum(o, 'row1', 'col1', 'row2', 'col2')
|
||||||
|
? { x: (o.col1 + o.col2) / 2, y: (o.row1 + o.row2) / 2 } : null;
|
||||||
|
case 'circle':
|
||||||
|
return allNum(o, 'row', 'col', 'radius') ? { x: o.col + o.radius, y: o.row } : null; // punto a destra
|
||||||
|
case 'arc': {
|
||||||
|
if (!allNum(o, 'row', 'col', 'radius', 'start_deg', 'extent_deg')) return null;
|
||||||
|
var a = rad(o.start_deg + o.extent_deg / 2); // punto medio dell'arco
|
||||||
|
return { x: o.col + o.radius * Math.cos(a), y: o.row - o.radius * Math.sin(a) };
|
||||||
|
}
|
||||||
|
case 'points':
|
||||||
|
return Array.isArray(o.rows) && Array.isArray(o.cols) && num(o.rows[0]) && num(o.cols[0])
|
||||||
|
? { x: o.cols[0], y: o.rows[0] } : null;
|
||||||
|
case 'rect2':
|
||||||
|
return allNum(o, 'row', 'col') ? { x: o.col, y: o.row } : null;
|
||||||
|
case 'contour': {
|
||||||
|
if (!Array.isArray(o.rows) || !Array.isArray(o.cols)) return null;
|
||||||
|
var i = Math.floor(Math.min(o.rows.length, o.cols.length) / 2);
|
||||||
|
return num(o.rows[i]) && num(o.cols[i]) ? { x: o.cols[i], y: o.rows[i] } : null;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #116: un'etichetta per riga con vision_output 'tool_id.proprieta' che ha un overlay ancorabile
|
||||||
|
function labels(rows, overlay) {
|
||||||
|
var out = [];
|
||||||
|
var items = Array.isArray(overlay) ? overlay : [];
|
||||||
|
(rows || []).forEach(function (r) {
|
||||||
|
if (!r || typeof r.vision_output !== 'string' || r.vision_output.indexOf('.') < 0) return;
|
||||||
|
var toolId = r.vision_output.split('.')[0];
|
||||||
|
var mine = items.filter(function (o) { return o && o.tool_id === toolId; });
|
||||||
|
// prima gli elementi 'found', poi qualsiasi altro
|
||||||
|
var order = mine.filter(function (o) { return o.role === 'found'; })
|
||||||
|
.concat(mine.filter(function (o) { return o.role !== 'found'; }));
|
||||||
|
var pt = null;
|
||||||
|
for (var i = 0; i < order.length && !pt; i++) pt = anchor(order[i]);
|
||||||
|
if (!pt) return;
|
||||||
|
// #118: valore null/undefined/vuoto/non finito = quota non misurata (Number(null) e' 0): etichetta NaN, status della riga (fail)
|
||||||
|
var v = (r.value === null || r.value === undefined || r.value === '' || typeof r.value === 'boolean')
|
||||||
|
? NaN : Number(r.value);
|
||||||
|
var txt = isFinite(v) ? v.toFixed(3) : 'NaN';
|
||||||
|
out.push({
|
||||||
|
key: r.key,
|
||||||
|
x: pt.x,
|
||||||
|
y: pt.y,
|
||||||
|
text: r.name + ' ' + txt + ' ' + (r.unit || ''),
|
||||||
|
status: r.status,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- #12: livelli e quote stile CAD ------------------------------------------------------------
|
||||||
|
// Coordinate in px immagine; angoli in gradi antiorari a video, versore u(a) = (cos a, -sin a),
|
||||||
|
// normale n = (-u.y, u.x). Contratto: gitea:Adriano/TieVision-Station#12.
|
||||||
|
|
||||||
|
// #12: colori ammessi per le quote (esiti del visore + rosso di default); mai una stringa dei dati nel markup
|
||||||
|
var QUOTA_COLORS = ['#16a34a', '#d97706', '#dc2626', '#ef4444'];
|
||||||
|
var QUOTA_ARROW = 9, QUOTA_GAP = 3, QUOTA_OVER = 6, QUOTA_LW = 1.5; // px a video
|
||||||
|
|
||||||
|
// #12: livello di una forma: campo "layer" se valido, altrimenti found = costruzioni, il resto = ricerca
|
||||||
|
function layerOf(o) {
|
||||||
|
var l = o && o.layer;
|
||||||
|
if (l === 'costruzioni' || l === 'ricerca' || l === 'quote') return l;
|
||||||
|
return o && o.role === 'found' ? 'costruzioni' : 'ricerca';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pt2(p) { return Array.isArray(p) && p.length === 2 && num(p[0]) && num(p[1]); }
|
||||||
|
function uVec(deg) { var a = rad(deg); return [Math.cos(a), -Math.sin(a)]; }
|
||||||
|
|
||||||
|
// #12: riferimento locale della quota {b, u, n, td, ...} o null se malformata
|
||||||
|
function quotaFrame(q) {
|
||||||
|
if (!q || typeof q !== 'object') return null;
|
||||||
|
var f = { tipo: q.tipo };
|
||||||
|
switch (q.tipo) {
|
||||||
|
case 'lineare': {
|
||||||
|
if (!pt2(q.p1) || !pt2(q.p2)) return null;
|
||||||
|
var dx = q.p2[0] - q.p1[0], dy = q.p2[1] - q.p1[1], L = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
f.u = L > 1e-12 ? [dx / L, dy / L] : [1, 0];
|
||||||
|
f.b = [(q.p1[0] + q.p2[0]) / 2, (q.p1[1] + q.p2[1]) / 2];
|
||||||
|
f.L = L; f.dflt = [0, 0];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'raggio':
|
||||||
|
if (!pt2(q.centro) || !num(q.r) || !num(q.dir_deg)) return null;
|
||||||
|
f.u = uVec(q.dir_deg); f.b = [q.centro[0], q.centro[1]]; f.dflt = [2 * q.r, 0];
|
||||||
|
break;
|
||||||
|
case 'angolo':
|
||||||
|
if (!pt2(q.centro) || !num(q.r) || !num(q.start_deg) || !num(q.extent_deg)) return null;
|
||||||
|
f.u = uVec(q.start_deg + q.extent_deg / 2); f.b = [q.centro[0], q.centro[1]]; f.dflt = [1.5 * q.r, 0];
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
f.n = [-f.u[1], f.u[0]];
|
||||||
|
f.td = pt2(q.testo) ? [q.testo[0], q.testo[1]] : f.dflt; // testo null o malformato = posizione di default
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// #12: punto T della scritta (px immagine) = B + du*u + dv*n; null se la quota e' malformata
|
||||||
|
function quotaPunto(q) {
|
||||||
|
var f = quotaFrame(q);
|
||||||
|
if (!f) return null;
|
||||||
|
return { x: f.b[0] + f.td[0] * f.u[0] + f.td[1] * f.n[0], y: f.b[1] + f.td[0] * f.u[1] + f.td[1] * f.n[1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// #12: inverso di quotaPunto: [du, dv] che porta la scritta nel punto (x, y); serve al trascinamento
|
||||||
|
function quotaTesto(q, x, y) {
|
||||||
|
var f = quotaFrame(q);
|
||||||
|
if (!f || !num(x) || !num(y)) return null;
|
||||||
|
var rx = x - f.b[0], ry = y - f.b[1];
|
||||||
|
return [rx * f.u[0] + ry * f.u[1], rx * f.n[0] + ry * f.n[1]];
|
||||||
|
}
|
||||||
|
|
||||||
|
function qLine(x1, y1, x2, y2, color) {
|
||||||
|
return '<line x1="' + n2(x1) + '" y1="' + n2(y1) + '" x2="' + n2(x2) + '" y2="' + n2(y2) + '" stroke="' + color +
|
||||||
|
'" stroke-width="' + QUOTA_LW + '" vector-effect="non-scaling-stroke"/>';
|
||||||
|
}
|
||||||
|
// #12: freccia piena con la punta in (x, y) rivolta verso (dx, dy) (versore); lunghezza fissa a video
|
||||||
|
function qArrow(x, y, dx, dy, zoom, color) {
|
||||||
|
var len = QUOTA_ARROW / zoom, w = len / 3;
|
||||||
|
var bx = x - dx * len, by = y - dy * len;
|
||||||
|
return '<polygon points="' + n2(x) + ',' + n2(y) + ' ' + n2(bx - dy * w) + ',' + n2(by + dx * w) + ' ' +
|
||||||
|
n2(bx + dy * w) + ',' + n2(by - dx * w) + '" fill="' + color + '" stroke="none"/>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function quotaSvgLineare(q, f, zoom, color) {
|
||||||
|
var out = [], u = f.u, n = f.n, dv = f.td[1], du = f.td[0], i;
|
||||||
|
var sg = dv >= 0 ? 1 : -1, gap = QUOTA_GAP / zoom, over = QUOTA_OVER / zoom;
|
||||||
|
var ps = [q.p1, q.p2];
|
||||||
|
if (Math.abs(dv) * zoom >= 3) { // linee di estensione: assenti se la linea di misura sta sul pezzo
|
||||||
|
for (i = 0; i < 2; i++) {
|
||||||
|
out.push(qLine(ps[i][0] + sg * gap * n[0], ps[i][1] + sg * gap * n[1],
|
||||||
|
ps[i][0] + (dv + sg * over) * n[0], ps[i][1] + (dv + sg * over) * n[1], color));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var m0 = [f.b[0] + dv * n[0], f.b[1] + dv * n[1]]; // punto medio della linea di misura
|
||||||
|
var h = f.L / 2, inside = f.L * zoom >= 2.5 * QUOTA_ARROW;
|
||||||
|
var lo = Math.min(-h, du), hi = Math.max(h, du); // la linea arriva fino alla scritta se sta oltre A o B
|
||||||
|
if (!inside) { lo = Math.min(lo, -h - 12 / zoom); hi = Math.max(hi, h + 12 / zoom); }
|
||||||
|
out.push(qLine(m0[0] + lo * u[0], m0[1] + lo * u[1], m0[0] + hi * u[0], m0[1] + hi * u[1], color));
|
||||||
|
var k = inside ? 1 : -1; // frecce corte: fuori, rivolte verso l'interno
|
||||||
|
out.push(qArrow(m0[0] - h * u[0], m0[1] - h * u[1], -k * u[0], -k * u[1], zoom, color));
|
||||||
|
out.push(qArrow(m0[0] + h * u[0], m0[1] + h * u[1], k * u[0], k * u[1], zoom, color));
|
||||||
|
return out.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function quotaSvgRaggio(q, f, zoom, color) {
|
||||||
|
var out = [], c = q.centro, T = quotaPunto(q);
|
||||||
|
var vx = T.x - c[0], vy = T.y - c[1], d = Math.sqrt(vx * vx + vy * vy);
|
||||||
|
var w = d > 1e-9 ? [vx / d, vy / d] : uVec(q.dir_deg);
|
||||||
|
var px = c[0] + q.r * w[0], py = c[1] + q.r * w[1], cr = 4 / zoom;
|
||||||
|
out.push(qLine(c[0] - cr, c[1], c[0] + cr, c[1], color)); // crocetta sul centro
|
||||||
|
out.push(qLine(c[0], c[1] - cr, c[0], c[1] + cr, color));
|
||||||
|
if (d > q.r) { // scritta fuori dal cerchio: da P a T, freccia in P verso il centro
|
||||||
|
out.push(qLine(px, py, T.x, T.y, color));
|
||||||
|
out.push(qArrow(px, py, -w[0], -w[1], zoom, color));
|
||||||
|
} else { // scritta dentro: dal centro a P, freccia in P verso fuori
|
||||||
|
out.push(qLine(c[0], c[1], px, py, color));
|
||||||
|
out.push(qArrow(px, py, w[0], w[1], zoom, color));
|
||||||
|
}
|
||||||
|
return out.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function quotaSvgAngolo(q, f, zoom, color) {
|
||||||
|
var out = [], c = q.centro, T = quotaPunto(q), i;
|
||||||
|
var rho = Math.sqrt((T.x - c[0]) * (T.x - c[0]) + (T.y - c[1]) * (T.y - c[1]));
|
||||||
|
if (!(rho > 1e-9)) rho = q.r > 0 ? 1.5 * q.r : 1;
|
||||||
|
var gap = QUOTA_GAP / zoom, over = QUOTA_OVER / zoom;
|
||||||
|
var ends = [q.start_deg, q.start_deg + q.extent_deg];
|
||||||
|
var sgn = q.extent_deg >= 0 ? 1 : -1;
|
||||||
|
var inside = rho * rad(Math.abs(q.extent_deg)) * zoom >= 2.5 * QUOTA_ARROW;
|
||||||
|
for (i = 0; i < 2; i++) {
|
||||||
|
var u = uVec(ends[i]);
|
||||||
|
var a = rho >= q.r ? q.r + gap : rho - over, b = rho >= q.r ? rho + over : q.r - gap;
|
||||||
|
out.push(qLine(c[0] + a * u[0], c[1] + a * u[1], c[0] + b * u[0], c[1] + b * u[1], color)); // estensione radiale
|
||||||
|
}
|
||||||
|
out.push('<path d="' + arcPath({ col: c[0], row: c[1], radius: rho, start_deg: q.start_deg, extent_deg: q.extent_deg }) +
|
||||||
|
'" fill="none" stroke="' + color + '" stroke-width="' + QUOTA_LW + '" vector-effect="non-scaling-stroke"/>');
|
||||||
|
for (i = 0; i < 2; i++) {
|
||||||
|
var ang = rad(ends[i]), uu = uVec(ends[i]);
|
||||||
|
var tg = [-Math.sin(ang), -Math.cos(ang)]; // tangente nel verso degli angoli crescenti
|
||||||
|
var dir = (i === 0 ? -1 : 1) * sgn * (inside ? 1 : -1); // dentro: verso fuori dall'arco; corte: verso l'interno
|
||||||
|
out.push(qArrow(c[0] + rho * uu[0], c[1] + rho * uu[1], dir * tg[0], dir * tg[1], zoom, color));
|
||||||
|
}
|
||||||
|
return out.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// #12: quota -> figli SVG (px immagine). opts = {zoom, color}; misure fisse a video divise per lo zoom.
|
||||||
|
// Solo numeri e colori da lista fissa nel markup.
|
||||||
|
function quotaSvg(q, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
var zoom = pos(opts.zoom, 1);
|
||||||
|
var color = QUOTA_COLORS.indexOf(opts.color) >= 0 ? opts.color : COLOR_OTHER;
|
||||||
|
try {
|
||||||
|
var f = quotaFrame(q);
|
||||||
|
if (!f) return '';
|
||||||
|
if (f.tipo === 'lineare') return quotaSvgLineare(q, f, zoom, color);
|
||||||
|
if (f.tipo === 'raggio') return quotaSvgRaggio(q, f, zoom, color);
|
||||||
|
return quotaSvgAngolo(q, f, zoom, color);
|
||||||
|
} catch (e) { return ''; } // una quota rotta non ferma le altre
|
||||||
|
}
|
||||||
|
|
||||||
|
return { draw: draw, anchor: anchor, labels: labels, svg: svg,
|
||||||
|
layerOf: layerOf, quotaPunto: quotaPunto, quotaTesto: quotaTesto, quotaSvg: quotaSvg };
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') window.VisionOverlay = VisionOverlay;
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = VisionOverlay;
|
||||||
@@ -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
|
||||||
@@ -215,6 +235,47 @@
|
|||||||
<p class="mt-1 text-xs text-[var(--text-secondary)]">{{ _('Vuoto = worker centrale. Indirizzo completo, con http o https') }}</p>
|
<p class="mt-1 text-xs text-[var(--text-secondary)]">{{ _('Vuoto = worker centrale. Indirizzo completo, con http o https') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# Camera della stazione (#72): servizio camera (vs-gui) e sorgente da cui prendere il frame #}
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Camera (URL)') }}</label>
|
||||||
|
<input type="text" x-model="form.camera_url" @change="loadCameraSources()"
|
||||||
|
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
|
||||||
|
text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
|
||||||
|
placeholder="host:8765"
|
||||||
|
maxlength="500">
|
||||||
|
<p class="mt-1 text-xs text-[var(--text-secondary)]">{{ _('Vuoto = nessuna camera. Indirizzo del servizio camera, con http o https, senza / finale') }}</p>
|
||||||
|
</div>
|
||||||
|
{# Sorgente camera (#88): testo libero con suggerimenti letti dall'acquisitore (datalist) #}
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Sorgente camera') }}</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input type="text" x-model="form.camera_source" list="cameraSourcesList"
|
||||||
|
class="flex-1 min-w-0 px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
|
||||||
|
text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
|
||||||
|
placeholder="camera:folder"
|
||||||
|
maxlength="200">
|
||||||
|
<datalist id="cameraSourcesList">
|
||||||
|
<template x-for="s in cameraSources" :key="s.name">
|
||||||
|
<option :value="s.name" :label="s.label"></option>
|
||||||
|
</template>
|
||||||
|
</datalist>
|
||||||
|
<button type="button" @click="loadCameraSources()"
|
||||||
|
:disabled="!(form.camera_url || '').trim() || cameraSourcesLoading"
|
||||||
|
class="px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)] text-sm font-medium
|
||||||
|
text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors whitespace-nowrap">
|
||||||
|
{{ _('Leggi sorgenti') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs"
|
||||||
|
:class="cameraSourcesError ? 'text-red-600 dark:text-red-400' : 'text-[var(--text-secondary)]'"
|
||||||
|
x-text="cameraSourcesStatus || {{ _('Es. camera:<seriale> oppure camera:folder')|tojson_attr }}"></p>
|
||||||
|
<p x-show="cameraSourceMissing" class="mt-1 text-xs text-amber-700 dark:text-amber-300">{{ _("Sorgente non presente nell'acquisitore") }}</p>
|
||||||
|
<a x-show="(form.camera_url || '').trim()"
|
||||||
|
:href="(form.camera_url || '').trim().replace(/\/+$/, '') + '/acquisizione'"
|
||||||
|
target="_blank" rel="noopener"
|
||||||
|
class="mt-1 inline-block text-xs font-medium text-primary hover:underline">{{ _('Apri pagina acquisizione') }}</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Note') }}</label>
|
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Note') }}</label>
|
||||||
<textarea x-model="form.notes" rows="3"
|
<textarea x-model="form.notes" rows="3"
|
||||||
@@ -439,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 {
|
||||||
@@ -464,8 +526,16 @@ function stationManagement(initialStations, initialRecipes) {
|
|||||||
location: '',
|
location: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
vision_worker_url: '', // #45
|
vision_worker_url: '', // #45
|
||||||
|
camera_url: '', // #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]');
|
||||||
@@ -513,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: '', 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;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -531,8 +636,11 @@ function stationManagement(initialStations, initialRecipes) {
|
|||||||
location: station.location || '',
|
location: station.location || '',
|
||||||
notes: station.notes || '',
|
notes: station.notes || '',
|
||||||
vision_worker_url: station.vision_worker_url || '', // #45
|
vision_worker_url: station.vision_worker_url || '', // #45
|
||||||
|
camera_url: station.camera_url || '', // #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;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -551,6 +659,8 @@ function stationManagement(initialStations, initialRecipes) {
|
|||||||
location: this.form.location || null,
|
location: this.form.location || null,
|
||||||
notes: this.form.notes || null,
|
notes: this.form.notes || null,
|
||||||
vision_worker_url: (this.form.vision_worker_url || '').trim() || null, // #45
|
vision_worker_url: (this.form.vision_worker_url || '').trim() || null, // #45
|
||||||
|
camera_url: (this.form.camera_url || '').trim() || null, // #72
|
||||||
|
camera_source: (this.form.camera_source || '').trim() || null, // #72
|
||||||
active: this.form.active,
|
active: this.form.active,
|
||||||
};
|
};
|
||||||
const resp = await fetch(`/admin/api/stations/${this.editingId}`, {
|
const resp = await fetch(`/admin/api/stations/${this.editingId}`, {
|
||||||
@@ -578,6 +688,8 @@ function stationManagement(initialStations, initialRecipes) {
|
|||||||
location: this.form.location || null,
|
location: this.form.location || null,
|
||||||
notes: this.form.notes || null,
|
notes: this.form.notes || null,
|
||||||
vision_worker_url: (this.form.vision_worker_url || '').trim() || null, // #45
|
vision_worker_url: (this.form.vision_worker_url || '').trim() || null, // #45
|
||||||
|
camera_url: (this.form.camera_url || '').trim() || null, // #72
|
||||||
|
camera_source: (this.form.camera_source || '').trim() || null, // #72
|
||||||
active: this.form.active,
|
active: this.form.active,
|
||||||
};
|
};
|
||||||
const resp = await fetch('/admin/api/stations', {
|
const resp = await fetch('/admin/api/stations', {
|
||||||
|
|||||||
@@ -157,7 +157,7 @@
|
|||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="text-center text-xs text-[var(--text-muted)]">
|
<div class="text-center text-xs text-[var(--text-muted)]">
|
||||||
<p>TieMeasureFlow © {{ current_year }} - {{ _('Sistema di misurazione industriale') }}</p>
|
<p>TieMeasureFlow © {{ current_year }} - {{ _('Sistema di misurazione industriale') }}{# versione e commit (#70) #} · v{{ app_version }}{% if app_commit %} · {{ app_commit }}{% endif %}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -107,7 +107,7 @@
|
|||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<footer class="py-4 text-center border-t border-[var(--border-color)] transition-colors duration-300">
|
<footer class="py-4 text-center border-t border-[var(--border-color)] transition-colors duration-300">
|
||||||
<p class="text-xs text-steel dark:text-steel-light tracking-wide">
|
<p class="text-xs text-steel dark:text-steel-light tracking-wide">
|
||||||
Powered by <span class="font-semibold">TieMeasureFlow</span> — Tielogic
|
Powered by <span class="font-semibold">TieMeasureFlow</span> — Tielogic{# versione e commit (#70) #} · v{{ app_version }}{% if app_commit %} · {{ app_commit }}{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -504,6 +504,7 @@
|
|||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
<script src="{{ url_for('static', filename='js/image-crop.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/image-crop.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/image_to_png.js') }}"></script>{# #107 #}
|
||||||
<script>
|
<script>
|
||||||
// #56: stringhe e percorsi per il ritaglio prima dell'upload
|
// #56: stringhe e percorsi per il ritaglio prima dell'upload
|
||||||
ImageCrop.configure({
|
ImageCrop.configure({
|
||||||
@@ -686,6 +687,13 @@ function recipeEditor() {
|
|||||||
async uploadFile(file) {
|
async uploadFile(file) {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
|
// #107: BMP e simili (leggibili dal browser, non dal backend) diventano PNG prima dei controlli
|
||||||
|
file = await tmfImageToPng(file);
|
||||||
|
if (!file) {
|
||||||
|
this.errorMessage = {{ _("Formato immagine non leggibile. Usa PNG, JPG o PDF.")|tojson }};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Validate file type
|
// Validate file type
|
||||||
const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf'];
|
const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf'];
|
||||||
if (!allowed.includes(file.type)) {
|
if (!allowed.includes(file.type)) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user