7 Commits

Author SHA1 Message Date
administrator
aa17d7ffd3 Add DearPyGUI observer/server UI and demo launcher 2026-06-04 08:57:36 +02:00
administrator
e86c05a885 External observer with OpenCV UI baseline 2026-06-03 15:28:27 +02:00
administrator
f728524ee6 pipeline in linea single thread 2026-05-19 08:52:44 +02:00
administrator
98b43ce903 aggiorna video test tagliato 2026-05-17 15:00:25 +02:00
administrator
1186c3bb35 configura fps preview e yolo 2026-05-16 09:10:48 +02:00
administrator
16458d98e9 aggiunta gui 2026-05-15 20:39:06 +02:00
administrator
8a8bea1211 Milestone navigation simulator with config 2026-05-15 18:40:07 +02:00
59 changed files with 9072 additions and 2 deletions

2
.gitignore vendored
View File

@@ -11,3 +11,5 @@ dataset_yolo/labels/*.cache
dataset_yolo/labels/*_backup_before_remap_*/ dataset_yolo/labels/*_backup_before_remap_*/
runs/flywms_dataset_check/ runs/flywms_dataset_check/
runs/flywms_dataset_check_1epoch/ runs/flywms_dataset_check_1epoch/
navigate_snapshots*/
wms_received*/

View File

@@ -0,0 +1,70 @@
# Aggiornamento 2026-05-16 09:03
## Decisione
Separare il ritmo della preview dal ritmo di inferenza YOLO.
Motivo: il video sorgente e' circa 30 fps, ma la pipeline completa non deve necessariamente elaborare ogni frame. Per la demo e per la futura pipeline reale e' piu' utile mantenere bassa la latenza, lasciando che la visualizzazione e YOLO abbiano frequenze configurabili.
## Implementazione
Aggiunti due parametri in `flywms_navigation.ini` e nel parser di `flywms_navigation.py`:
- `preview_fps`: FPS massimo per lettura/preview realtime. `0` usa il framerate della sorgente.
- `yolo_fps`: FPS massimo per inferenza YOLO. `0` esegue YOLO su ogni frame di preview.
Valori iniziali:
```ini
preview_fps = 24.0
yolo_fps = 15.0
```
La stessa logica e' stata applicata anche alla shell DearPyGUI `flywms_navigation_gui.py`.
## Nota su video e camera reale
Con un file video, questi parametri simulano il ritmo desiderato della preview e limitano l'inferenza YOLO. In questa versione single-thread, se YOLO e' piu' lento del target, non si puo' arrivare al target.
Con una camera reale, il codice prova anche a impostare `CAP_PROP_FPS` quando la sorgente e' webcam/camera. Questo e' best effort: molti driver ignorano il valore. Il metodo robusto resta scartare frame lato software e usare sempre il frame piu' recente disponibile.
## Verifiche
Compilazione:
```powershell
python -m py_compile flywms_navigation.py flywms_navigation_gui.py
```
Lettura config:
```text
24.0 15.0
```
Run headless con `yolo_fps=15` su CPU:
```text
fps=5.5 yolo_fps=5.5
```
Interpretazione: su CPU il ciclo non raggiunge 15 fps, quindi YOLO gira comunque su ogni frame effettivamente processato.
Run headless con `yolo_fps=2`:
```text
fps=13.7 yolo_fps=2.0
```
Interpretazione: il throttling YOLO funziona; la preview procede piu' veloce dell'inferenza.
## Prossimo passo
Quando arrivera' la GPU, rimisurare con:
- `preview_fps = 24.0`;
- `yolo_fps = 15.0`;
- device GPU Ultralytics;
- GUI DearPyGUI attiva.
Se il costo GUI diventera' dominante, separare in seguito thread inferenza e thread GUI con stato latest-frame.

View File

@@ -0,0 +1,99 @@
# Aggiornamento 2026-05-16 10:14
## Obiettivo
Cambiare il payload destinato all'OCR: non piu' solo il crop del gaylord, ma il crop ravvicinato dell'etichetta associata al gaylord centrato.
Il gaylord resta l'ancora semantica: serve per sapere a quale oggetto appartiene l'etichetta. L'etichetta diventa il target ottico da centrare e fotografare.
## Decisioni
- L'etichetta accettata deve avere classe `etichetta`.
- Il bbox etichetta deve essere contenuto nel bbox del gaylord centrato.
- Detection etichetta fuori da un gaylord vengono ignorate.
- Se il gaylord e' centrato ma non c'e' etichetta contenuta, viene loggato `ETICHETTA_NON_TROVATA` e si attende un frame successivo.
- Il crop etichetta usa padding configurabile.
- Il sistema mantiene sia snapshot/crop gaylord sia crop etichetta.
- Il movimento drone verso l'etichetta e il ritorno al gaylord sono simulati in pixel.
## Parametri aggiunti
In `flywms_navigation.ini`:
```ini
label_class = etichetta
label_payload_pad_ratio = 0.20
label_move_sec = 2.0
label_stabilization_sec = 1.0
label_return_sec = 2.0
```
## Comportamento OpenCV
Quando il gaylord centrato ha un'etichetta associata:
1. si ferma la scansione;
2. viene disegnata una freccia nera bidirezionale dal centro del gaylord al centro dell'etichetta;
3. vengono mostrati i comandi di centraggio etichetta;
4. viene simulato il movimento verso l'etichetta;
5. viene simulata la stabilizzazione;
6. viene salvato il crop dell'etichetta;
7. viene simulato il ritorno al centro gaylord;
8. viene ripresa la scansione.
La finestra `flywms etichetta` mostra il crop dell'etichetta. La finestra `flywms snapshot` continua a mostrare il crop/debug del gaylord.
## Metadati
Ogni record JSONL ora include:
- `gaylord_bbox`;
- `label_bbox`;
- `movement_vector_px`;
- `debug_frame_path`;
- `ocr_payload_path`;
- `label_payload_path`.
## Verifiche
Compilazione:
```powershell
python -m py_compile flywms_navigation.py flywms_navigation_gui.py
```
Run headless ordinario su 80 frame:
```text
det=2 labels=1 tracks=2 snapshots=0
```
Nessuno snapshot nei primi 80 frame perche' il gaylord non era ancora sulla linea stretta di scatto.
Run headless forzato con tolleranza larga:
```powershell
python flywms_navigation.py --no-display --max-frames 6 --remote-ack-timeout-sec 0 --label-move-sec 0 --label-stabilization-sec 0 --label-return-sec 0 --snapshot-line-tolerance-ratio 0.20 --snapshot-output-dir navigate_snapshots_test --preview-fps 0 --yolo-fps 0
```
Risultato:
```text
SNAPSHOT 0001 track=2 frame=3 pos=gaylord 1
CENTRA_ETICHETTA dx=+314px dy=-498px
SCATTA_FOTO_ETICHETTA snapshot_0001_track_002_label_payload.jpg
INVIA_ROI_REMOTA snapshot_0001_track_002_label_payload.jpg
```
Crop generati:
```text
label_payload: (94, 371, 3)
ocr_payload/gaylord: (1079, 1114, 3)
```
## Limiti noti
- Il tracking etichetta non e' ancora un tracker separato; per ora usa la detection etichetta contenuta nel gaylord durante la finestra candidata.
- Se piu' etichette sono contenute nello stesso gaylord, viene scelto un fallback deterministico. In condizioni corrette non dovrebbe accadere.
- La GUI DearPyGUI e' stata aggiornata solo per non regredire, ma la validazione visuale di questa fase e' pensata sulle finestre OpenCV.

View File

@@ -0,0 +1,41 @@
# Aggiornamento 2026-05-16 10:37
## Obiettivo
Rendere piu' leggibile la simulazione di movimento verso l'etichetta nella finestra OpenCV dei comandi.
## Modifiche
- Aumentate di un secondo le pause configurate:
- `label_move_sec`: da 2.0 a 3.0;
- `label_stabilization_sec`: da 1.0 a 2.0;
- `label_return_sec`: da 2.0 a 3.0.
- Aggiunto un riquadro grafico nella finestra `flywms comandi`.
- Durante `CENTRA_ETICHETTA` il riquadro mostra una freccia grande dal centro gaylord verso nord-est/etichetta.
- Durante `RITORNA_CENTRO_GAYLORD` il riquadro mostra una freccia grande dall'etichetta verso il centro gaylord.
## Verifiche
Compilazione:
```powershell
python -m py_compile flywms_navigation.py flywms_navigation_gui.py
```
Lettura configurazione:
```text
3.0 2.0 3.0
```
Run headless forzato:
```powershell
python flywms_navigation.py --no-display --max-frames 6 --remote-ack-timeout-sec 0 --label-move-sec 0 --label-stabilization-sec 0 --label-return-sec 0 --snapshot-line-tolerance-ratio 0.20 --snapshot-output-dir navigate_snapshots_test --preview-fps 0 --yolo-fps 0
```
La verifica visuale va fatta con le finestre OpenCV:
```powershell
python flywms_navigation.py
```

View File

@@ -0,0 +1,30 @@
# Aggiornamento 2026-05-16 10:47
## Obiettivo
Rendere piu' leggibili nella finestra comandi i delta di spostamento verso l'etichetta e di ritorno al centro gaylord.
## Modifiche
- Nel riquadro grafico della finestra `flywms comandi` vengono ora mostrati in grande:
- `DX ORIZZONTALE`;
- `DY VERTICALE`.
- Durante `CENTRA_ETICHETTA` i valori sono quelli dal centro gaylord al centro etichetta.
- Durante `RITORNA_CENTRO_GAYLORD` i valori sono invertiti, quindi rappresentano il movimento dall'etichetta al centro gaylord.
- Il comando visuale della fase di ritorno contiene ora anche `dx` e `dy`.
## Verifiche
Compilazione:
```powershell
python -m py_compile flywms_navigation.py flywms_navigation_gui.py
```
Run headless forzato:
```powershell
python flywms_navigation.py --no-display --max-frames 6 --remote-ack-timeout-sec 0 --label-move-sec 0 --label-stabilization-sec 0 --label-return-sec 0 --snapshot-line-tolerance-ratio 0.20 --snapshot-output-dir navigate_snapshots_test --preview-fps 0 --yolo-fps 0
```
La verifica effettiva dei delta grandi va fatta con finestra OpenCV attiva.

View File

@@ -0,0 +1,50 @@
# Aggiornamento 2026-05-16 10:49
## Obiettivo
Rendere configurabile la posizione e dimensione delle finestre OpenCV.
## Modifiche
Aggiunti parametri in `flywms_navigation.ini`:
```ini
window_layout_enabled = true
navigate_window = 20,40,1100,620
commands_window = 1140,40,760,520
snapshot_window = 1140,590,520,360
label_window = 1140,980,520,260
```
Formato:
```text
x,y,width,height
```
In `flywms_navigation.py` sono state aggiunte:
- `parse_window_rect`;
- `apply_window_layout`.
La funzione viene chiamata subito dopo la creazione delle finestre OpenCV.
## Verifiche
Compilazione:
```powershell
python -m py_compile flywms_navigation.py flywms_navigation_gui.py
```
Lettura configurazione:
```text
True
20,40,1100,620
1140,40,760,520
1140,590,520,360
1140,980,520,260
```
Nota: OpenCV applica posizione e dimensione best effort. Su Windows di solito funziona, ma DPI scaling e multi-monitor possono introdurre differenze.

View File

@@ -0,0 +1,110 @@
# Aggiornamento 2026-05-16 11:04
## Milestone prima implementazione comunicazione WMS
Questa milestone documenta la specifica concordata prima di completare la parte client-server reale tra `flywms_navigation.py` e un server WMS demo.
## Obiettivo
Implementare una comunicazione reale, asincrona e configurabile tra:
- client navigazione: `flywms_navigation.py`;
- server WMS demo: nuovo script FastAPI.
Il client deve inviare il crop etichetta e i metadati dello snapshot. Il server deve ricevere, salvare i payload per debug, simulare OCR/WMS e rispondere `ACK` o `NACK`.
## Decisioni concordate
- Server demo basato su FastAPI.
- Dipendenze installate:
- `fastapi`;
- `uvicorn`;
- `python-multipart`.
- Il server deve salvare fisicamente le immagini ricevute in una cartella di debug.
- Modalita' risposta configurabili:
- `always-ack`;
- `always-nack`;
- `alternate`;
- `random`.
- Il client deve poter inviare a un server su altro computer tramite IP/porta configurabili.
- Il loop video/navigazione non deve fare la POST direttamente: invio tramite worker asincrono.
- La sequenza logica rimane:
- stop;
- scatta etichetta;
- invia WMS;
- attende risposta o timeout;
- riparte.
- L'attesa della risposta deve essere gestita senza congelare brutalmente il rendering.
## Configurazione prevista lato navigazione
Da aggiungere/validare in `flywms_navigation.ini`:
```ini
wms_enabled = false
wms_server_url = http://127.0.0.1:8088/api/v1/navigation-snapshot
wms_client_id = flywms-demo-01
wms_timeout_sec = 2.0
wms_queue_max_size = 8
wms_send_gaylord_debug = true
```
Per server remoto:
```ini
wms_server_url = http://192.168.1.50:8088/api/v1/navigation-snapshot
```
## Configurazione prevista lato server
Da implementare in `flywms_wms_server.py`:
```text
host = 0.0.0.0
port = 8088
fake_ack_mode = always-ack
fake_processing_sec = 0.5
received_dir = wms_received
```
## Protocollo previsto
Endpoint:
```text
POST /api/v1/navigation-snapshot
```
Payload multipart:
- `metadata`: JSON;
- `label_image`: JPEG crop etichetta;
- `gaylord_image`: JPEG opzionale/debug.
Risposta:
```json
{
"status": "ACK",
"request_id": "...",
"message": "codice valido su WMS",
"fake_ocr_text": "DEMO-0001",
"processing_ms": 120
}
```
Oppure:
```json
{
"status": "NACK",
"request_id": "...",
"message": "codice non riconosciuto",
"fake_ocr_text": "",
"processing_ms": 120
}
```
## Nota operativa
Al momento della creazione di questa milestone erano gia' stati installati i pacchetti FastAPI e iniziati i primi campi/config del client WMS. La milestone serve a fissare la specifica prima di proseguire con il completamento.

View File

@@ -0,0 +1,132 @@
# Aggiornamento 2026-05-16 11:12
## Obiettivo
Completare la prima implementazione reale client-server tra `flywms_navigation.py` e un server WMS demo.
## Implementazione
### Client navigazione
In `flywms_navigation.py` sono stati aggiunti:
- `WmsSnapshotJob`;
- `WmsResult`;
- `WmsAsyncClient`;
- creazione multipart HTTP senza base64;
- worker thread con coda non bloccante;
- invio del crop etichetta e, se configurato, del frame/debug gaylord;
- attesa di risposta WMS dopo il ritorno simulato al centro gaylord;
- gestione `ACK`, `NACK`, `ERROR`, `TIMEOUT`.
Il loop video non esegue direttamente la POST: accoda il job e il worker invia in background.
### Server WMS demo
Aggiunto `flywms_wms_server.py` basato su FastAPI.
Endpoint:
```text
POST /api/v1/navigation-snapshot
GET /health
```
Payload multipart:
- `metadata`: JSON;
- `label_image`: JPEG crop etichetta;
- `gaylord_image`: JPEG opzionale/debug.
Il server salva ogni richiesta in:
```text
wms_received/
```
oppure nella directory passata con `--received-dir`.
Modalita risposta:
- `always-ack`;
- `always-nack`;
- `alternate`;
- `random`.
### Configurazione
Parametri aggiunti in `flywms_navigation.ini`:
```ini
wms_enabled = false
wms_server_url = http://127.0.0.1:8088/api/v1/navigation-snapshot
wms_client_id = flywms-demo-01
wms_timeout_sec = 2.0
wms_queue_max_size = 8
wms_send_gaylord_debug = true
wms_server_host = 0.0.0.0
wms_server_port = 8088
wms_received_dir = wms_received
wms_fake_ack_mode = always-ack
wms_fake_processing_sec = 0.5
```
Aggiornato `.gitignore`:
```text
wms_received*/
```
## Test end-to-end
Server avviato in job PowerShell locale su:
```text
http://127.0.0.1:8088
```
Health check:
```json
{"status":"ok"}
```
Client headless con snapshot forzato:
```powershell
python flywms_navigation.py --no-display --max-frames 6 --snapshot-line-tolerance-ratio 0.20 --snapshot-output-dir navigate_snapshots_test --preview-fps 0 --yolo-fps 0 --label-move-sec 0 --label-stabilization-sec 0 --label-return-sec 0 --remote-ack-timeout-sec 5 --wms-enabled --wms-server-url http://127.0.0.1:8088/api/v1/navigation-snapshot
```
Risultato client:
```text
[WMS] job accodato request_id=77cba060-1433-43f3-a387-3811e6734907 snapshot=1
[WMS] risposta request_id=77cba060-1433-43f3-a387-3811e6734907 status=ACK message=codice valido su WMS
[WMS] ACK request_id=77cba060-1433-43f3-a387-3811e6734907 snapshot=1 codice valido su WMS
[CMD] RIPARTI_DESTRA
```
Risultati salvati dal server:
```text
wms_received_test/000001_77cba060-1433-43f3-a387-3811e6734907/metadata.json
wms_received_test/000001_77cba060-1433-43f3-a387-3811e6734907/snapshot_0001_track_002_frame.jpg
wms_received_test/000001_77cba060-1433-43f3-a387-3811e6734907/snapshot_0001_track_002_label_payload.jpg
```
Metadati ricevuti includono:
- `request_id`;
- `client_id`;
- `snapshot_id`;
- `simulated_position`;
- `track_id`;
- `gaylord_bbox`;
- `label_bbox`;
- `movement_vector_px`.
## Note
- La sequenza fisica simulata resta: movimento verso etichetta, stabilizzazione, scatto, ritorno al gaylord.
- L'invio WMS parte dopo lo scatto etichetta e puo' procedere mentre viene simulato il ritorno.
- La ripartenza viene comandata solo dopo risposta WMS o timeout.

View File

@@ -0,0 +1,49 @@
# Aggiornamento 2026-05-16 11:29
## Bugfix
Corretto crash all'avvio di `flywms_navigation.py --wms-enabled`.
Errore:
```text
AttributeError: 'Namespace' object has no attribute 'window_layout_enabled'
```
## Causa
I parametri layout finestre erano presenti in `flywms_navigation.ini` e nei default di `load_navigation_config`, ma mancavano in `parse_args()`.
## Correzione
Aggiunti argomenti CLI:
```text
--window-layout-enabled
--navigate-window
--commands-window
--snapshot-window
--label-window
```
## Verifiche
Compilazione:
```powershell
python -m py_compile flywms_navigation.py flywms_navigation_gui.py flywms_wms_server.py
```
Parsing argomenti:
```text
True 20,40,1100,620 1140,40,760,520 1140,590,520,360 1140,980,520,260
```
Run headless:
```powershell
python flywms_navigation.py --no-display --max-frames 1 --wms-enabled
```
Risultato: avvio e chiusura corretti senza crash.

View File

@@ -0,0 +1,62 @@
# Aggiornamento 2026-05-16 12:04
## Milestone prima implementazione OCR lato WMS server
## Obiettivo
Evolvere `flywms_wms_server.py` da server demo ACK/NACK a server WMS demo con pipeline remota:
1. riceve crop etichetta e metadati dal client navigazione;
2. mostra l'immagine ricevuta in una finestra server;
3. esegue OCR sul crop etichetta;
4. prepara payload WMS finale;
5. salva payload WMS su disco;
6. simula verifica WMS con risposta `ACK`/`NACK`;
7. risponde realmente al client.
## Decisioni concordate
- Il codice OCR deve idealmente essere il codice numerico reale sull'etichetta.
- Se OCR reale non e' disponibile o non legge nulla, usare fallback demo configurabile.
- Il server deve rispondere realmente al client.
- Il client, per ora, continua a mostrare la risposta ma mantiene la logica di attesa demo gia' configurata.
- In futuro il tempo di attesa verra' spostato completamente lato server e il client si adeguera' alla risposta.
- La finestra payload server mostra solo l'ultimo gaylord ricevuto.
- Il server salva anche il payload WMS finale in JSON, oltre al metadata originale.
- L'esito `ACK/NACK` resta governato da `wms_fake_ack_mode`.
## Finestre server previste
- `wms immagine ricevuta`: crop etichetta ricevuto dal client.
- `wms payload`: ultimo payload WMS con informazioni utili:
- request id;
- client id / drone;
- snapshot id;
- posizione;
- codice OCR;
- esito WMS;
- data/ora ricezione;
- track id;
- bbox etichetta;
- vettore movimento.
## Configurazione prevista
Da aggiungere in `flywms_navigation.ini`:
```ini
wms_ui_enabled = true
wms_ocr_enabled = true
wms_ocr_mode = auto
wms_fake_ocr_prefix = UDC
wms_fake_ocr_delay_sec = 0.2
wms_fake_wms_delay_sec = 1.0
wms_operator = udrone
wms_site = demo-magazzino
```
## Note tecniche
La GUI OpenCV del server non deve essere aggiornata direttamente dall'handler HTTP. L'handler aggiorna stato condiviso thread-safe; un thread UI separato aggiorna le finestre OpenCV.
L'OCR reale verra' implementato cercando prima le opzioni gia' disponibili nell'ambiente/progetto. Se non disponibili, il server resta funzionante con fallback demo e lo segnala nel payload.

View File

@@ -0,0 +1,116 @@
# Aggiornamento 2026-05-16 12:08
## Obiettivo
Implementare lato `flywms_wms_server.py` la prima pipeline WMS/OCR demo con finestre server.
## Implementazione server
Il server ora:
- riceve crop etichetta e metadati;
- salva `metadata.json`;
- esegue OCR sul crop etichetta;
- salva `wms_payload.json`;
- aggiorna una finestra OpenCV con l'immagine ricevuta;
- aggiorna una finestra OpenCV con il payload WMS dell'ultimo gaylord;
- risponde al client con `ACK/NACK` e codice OCR.
## OCR
Motore implementato:
- EasyOCR, se disponibile;
- fallback demo se OCR non legge nulla o se viene avviato con `--no-ocr`.
Sul crop di test, EasyOCR e' disponibile ma non ha letto testo utile; e' stato usato fallback:
```text
UDC-0001
```
Il payload indica esplicitamente:
```json
"ocr_backend": "fake",
"ocr_fallback_used": true
```
oppure `easyocr-fallback` quando EasyOCR gira ma non trova testo.
## Configurazione aggiunta
In `flywms_navigation.ini`:
```ini
wms_ui_enabled = true
wms_ocr_enabled = true
wms_ocr_mode = easyocr
wms_fake_ocr_prefix = UDC
wms_fake_ocr_delay_sec = 0.2
wms_operator = udrone
wms_site = demo-magazzino
```
Il server supporta anche:
```powershell
--no-ui
--no-ocr
```
per test headless.
## Client
Il client navigazione ora mostra anche il codice OCR ricevuto:
```text
OCR_CODICE UDC-0001
```
La risposta server include nel messaggio:
```text
codice valido su WMS: UDC-0001
```
## Verifiche
Compilazione:
```powershell
python -m py_compile flywms_wms_server.py flywms_navigation.py flywms_navigation_gui.py
```
Test OCR diretto sul crop etichetta:
```text
OcrServerResult(text='UDC-0001', raw_text='', confidence=0.0, backend='easyocr-fallback', fallback_used=True)
```
Test end-to-end server/client con server headless:
```powershell
python flywms_wms_server.py --host 127.0.0.1 --port 8088 --received-dir wms_received_test --fake-processing-sec 0.1 --fake-ocr-delay-sec 0 --no-ui --no-ocr
```
Client:
```powershell
python flywms_navigation.py --no-display --max-frames 6 --snapshot-line-tolerance-ratio 0.20 --snapshot-output-dir navigate_snapshots_test --preview-fps 0 --yolo-fps 0 --label-move-sec 0 --label-stabilization-sec 0 --label-return-sec 0 --remote-ack-timeout-sec 5 --wms-enabled --wms-server-url http://127.0.0.1:8088/api/v1/navigation-snapshot
```
Risultato:
```text
[WMS] risposta request_id=... status=ACK message=codice valido su WMS: UDC-0001
[WMS] ACK request_id=... snapshot=1 codice valido su WMS: UDC-0001
[CMD] RIPARTI_DESTRA
```
## Note
- La finestra server mostra l'ultimo payload ricevuto.
- Il server salva `wms_payload.json` oltre a `metadata.json`.
- L'esito `ACK/NACK` resta governato da `wms_fake_ack_mode`.

View File

@@ -0,0 +1,43 @@
# Aggiornamento 2026-05-16 12:10
## Obiettivo
Evitare falsi codici UDC quando OCR non determina davvero il testo.
## Decisione
Il primo gaylord ha etichetta tagliata. In quel caso il server non deve inventare un codice come `UDC-0001`.
Quando OCR fallisce o non legge testo utile, il codice restituito deve essere:
```text
udc non determinato
```
## Modifiche
In `flywms_wms_server.py`:
- cambiato fallback OCR;
- aggiunto campo configurabile `undetermined_code_text`;
- il payload mantiene `ocr_fallback_used = true`.
In `flywms_navigation.ini`:
```ini
wms_undetermined_code_text = udc non determinato
```
## Verifiche
Compilazione:
```powershell
python -m py_compile flywms_wms_server.py flywms_navigation.py flywms_navigation_gui.py
```
Test OCR sul crop del primo gaylord:
```text
OcrServerResult(text='udc non determinato', raw_text='', confidence=0.0, backend='easyocr-fallback', fallback_used=True)
```

View File

@@ -0,0 +1,188 @@
# Aggiornamento 2026-05-16 13:01
## Salvataggio discussione prima del riavvio PC
Questo file riassume lo stato della discussione e del lavoro fatto finora, per evitare perdita di contesto in caso di crash o riavvio.
## Stato generale
Il progetto FlyWMS sta evolvendo da una demo locale di navigazione YOLO/OpenCV verso una pipeline distribuita:
- `flywms_navigation.py`: simulatore drone/navigazione;
- `flywms_wms_server.py`: server WMS demo;
- comunicazione reale HTTP multipart tra client navigazione e server WMS;
- crop etichetta come payload principale per OCR/WMS;
- gaylord come ancora semantica per associare etichetta e posizione.
## Decisioni importanti prese
- Il gaylord resta l'oggetto di riferimento: serve a sapere a quale UDC/posizione appartiene l'etichetta.
- L'OCR deve lavorare sul crop dell'etichetta, non sul crop del gaylord intero.
- L'etichetta valida deve avere bbox contenuto nel bbox del gaylord centrato.
- Detection etichetta fuori dal gaylord vengono ignorate.
- Se il gaylord e' centrato ma non viene trovata un'etichetta contenuta, si segnala `ETICHETTA_NON_TROVATA` e si attende un frame successivo.
- Il movimento drone verso etichetta e ritorno al centro gaylord e' simulato con vettore pixel `dx/dy`.
- La freccia nera bidirezionale rappresenta il movimento dal centro gaylord al centro etichetta.
- La finestra comandi mostra anche grandi delta:
- `DX ORIZZONTALE`;
- `DY VERTICALE`.
- Le finestre OpenCV client sono configurabili da INI.
- Il server WMS non era inizialmente dotato di finestre; poi e' stato deciso di aggiungere finestre server demo.
- Il server WMS deve salvare immagini e payload ricevuti.
- L'esito `ACK/NACK` per ora resta configurabile e simulato.
- Il codice OCR deve essere reale quando possibile; se OCR non determina il codice, non bisogna inventare un valore.
- Per il primo gaylord, avendo etichetta tagliata, il codice deve essere:
```text
udc non determinato
```
## File principali modificati o aggiunti
- `flywms_navigation.py`
- tracking gaylord;
- associazione etichetta contenuta nel bbox gaylord;
- crop etichetta;
- freccia movimento;
- finestre OpenCV;
- client WMS asincrono;
- invio HTTP multipart;
- gestione ACK/NACK/ERROR/TIMEOUT.
- `flywms_navigation.ini`
- parametri navigazione;
- parametri FPS preview/YOLO;
- parametri etichetta;
- parametri finestre OpenCV;
- parametri client/server WMS;
- parametri OCR/fallback.
- `flywms_wms_server.py`
- server FastAPI;
- endpoint `/api/v1/navigation-snapshot`;
- endpoint `/health`;
- salvataggio immagini e metadati;
- OCR EasyOCR con fallback;
- payload WMS finale;
- finestre OpenCV server;
- opzioni `--no-ui` e `--no-ocr`.
- `.gitignore`
- aggiunto `wms_received*/`.
## Milestone create nella sessione
- `aggiornamento-2026-05-16-09-03.md`
- `aggiornamento-2026-05-16-10-14.md`
- `aggiornamento-2026-05-16-10-37.md`
- `aggiornamento-2026-05-16-10-47.md`
- `aggiornamento-2026-05-16-10-49.md`
- `aggiornamento-2026-05-16-11-04.md`
- `aggiornamento-2026-05-16-11-12.md`
- `aggiornamento-2026-05-16-11-29.md`
- `aggiornamento-2026-05-16-12-04.md`
- `aggiornamento-2026-05-16-12-08.md`
- `aggiornamento-2026-05-16-12-10.md`
## Test eseguiti
Compilazione piu' volte:
```powershell
python -m py_compile flywms_navigation.py flywms_navigation_gui.py flywms_wms_server.py
```
Test client-server WMS locale:
```text
[WMS] job accodato request_id=...
[WMS] risposta request_id=... status=ACK message=codice valido su WMS: ...
[CMD] RIPARTI_DESTRA
```
Server ha salvato:
```text
metadata.json
wms_payload.json
snapshot_..._frame.jpg
snapshot_..._label_payload.jpg
```
OCR su primo crop etichetta:
```text
OcrServerResult(text='udc non determinato', raw_text='', confidence=0.0, backend='easyocr-fallback', fallback_used=True)
```
## Problema attuale prima del riavvio
Eseguendo:
```powershell
python flywms_navigation.py --wms-enabled
```
il programma sembra non partire. Dopo `CTRL+C`, lo stack trace mostra che era fermo durante l'import di Ultralytics/PyTorch:
```text
detector = UltralyticsDetector(args.weights, args.ultralytics_device)
from ultralytics import YOLO
import torch
KeyboardInterrupt
```
Questo indica che il blocco avviene prima dell'avvio della logica WMS e prima dell'apertura del video: il processo sta impiegando molto tempo a importare `torch`/`ultralytics` o e' rallentato da stato del PC/disco/ambiente Python.
Non sembra causato direttamente dal codice WMS, perche' lo stack trace si ferma in import PyTorch.
## Diagnosi da fare dopo riavvio
1. Verificare tempo import PyTorch/Ultralytics:
```powershell
python -c "import time; t=time.time(); import torch; print('torch', time.time()-t); t=time.time(); from ultralytics import YOLO; print('ultralytics', time.time()-t)"
```
2. Verificare avvio senza WMS:
```powershell
python flywms_navigation.py --no-display --max-frames 1
```
3. Verificare avvio con WMS ma senza finestre:
```powershell
python flywms_navigation.py --no-display --max-frames 1 --wms-enabled
```
4. Avviare server WMS:
```powershell
python flywms_wms_server.py --host 0.0.0.0 --port 8088
```
5. Avviare navigazione:
```powershell
python flywms_navigation.py --wms-enabled
```
## Stato git noto
Ultimo commit locale fatto:
```text
1186c3b configura fps preview e yolo
```
Il branch risultava avanti di 1 commit rispetto a `origin/master` prima delle modifiche successive.
Molte modifiche successive risultano non ancora committate. Prima di proseguire dopo il riavvio conviene fare:
```powershell
git status --short --branch
python -m py_compile flywms_navigation.py flywms_navigation_gui.py flywms_wms_server.py
```
e poi valutare commit/push.

View File

@@ -0,0 +1,26 @@
# Aggiornamento 2026-05-16 14:34
## Milestone test Tesseract OCR
## Obiettivo
Valutare Tesseract come alternativa piu' leggera a EasyOCR per leggere codici numerici sulle etichette.
## Motivazione
EasyOCR si e' rivelato fragile sui crop piccoli/tagliati e molto pesante in RAM/tempo di avvio. Per codici numerici semplici, Tesseract con whitelist `0123456789` potrebbe essere piu' controllabile.
## Strategia
- Verificare se `tesseract.exe` e' installato.
- Se manca, installare/abilitare Tesseract e il package Python `pytesseract`.
- Aggiungere supporto server a `wms_ocr_mode = tesseract`.
- Usare OCR numerico con:
- preprocess OpenCV;
- whitelist cifre;
- `--psm 7` o simile;
- fallback `udc non determinato` se non legge un codice valido.
## Nota
Tesseract va considerato un test, non ancora la soluzione definitiva. Se confonde cifre o produce risultati instabili, dovra' restituire `udc non determinato`.

View File

@@ -0,0 +1,75 @@
# Aggiornamento 2026-05-16 17:18
## Test supporto Tesseract
## Stato
E' stato installato il wrapper Python:
```powershell
python -m pip install pytesseract
```
Sul PC pero' non risulta installato il binario:
```text
tesseract.exe
```
`where tesseract` non trova nulla.
## Modifiche implementate
In `flywms_wms_server.py` e' stato aggiunto supporto opzionale a:
```text
wms_ocr_mode = tesseract
```
Il server ora:
- usa `pytesseract` se disponibile;
- usa whitelist numerica `0123456789`;
- prova piu' preprocess OpenCV;
- richiede consenso tra almeno due varianti;
- se Tesseract non e' installato o il risultato e' ambiguo, restituisce `udc non determinato`.
E' stato aggiunto un controllo preventivo:
- se `wms_tesseract_cmd` e' configurato ma il file non esiste, fallback immediato;
- se `tesseract` non e' nel PATH, fallback immediato.
Questo evita che `pytesseract` resti appeso cercando un binario assente.
## Verifica eseguita
Sul crop:
```text
navigate_snapshots/snapshot_0002_track_003_label_payload.jpg
```
con OCR mode `tesseract`, senza tesseract installato:
```text
Tesseract fallback: tesseract.exe non trovato nel PATH
OcrServerResult(text='udc non determinato', raw_text='', confidence=0.0, backend='tesseract-missing', fallback_used=True)
```
## Nota
Il file `flywms_navigation.ini` al momento non e' stato aggiornato con `wms_tesseract_cmd` perche' il patch tool non e' riuscito a scriverlo in questa fase. I default del server contengono gia' `wms_tesseract_cmd = ""`, quindi il codice resta funzionante. Va aggiornato l'INI appena il file non risulta piu' bloccato.
## Prossimo passo
Installare Tesseract per Windows e poi testare:
```powershell
python flywms_wms_server.py --ocr-mode tesseract
```
Se `tesseract.exe` non e' nel PATH, avviare con:
```powershell
python flywms_wms_server.py --ocr-mode tesseract --tesseract-cmd "C:\Program Files\Tesseract-OCR\tesseract.exe"
```

View File

@@ -0,0 +1,84 @@
# Aggiornamento 2026-05-16 17:19
## Milestone OCR alternativo
## Stato corrente
EasyOCR e' stato giudicato troppo fragile e troppo pesante per la demo attuale:
- confonde cifre critiche, ad esempio `8` e `2`;
- puo' consumare molta RAM;
- puo' rallentare o bloccare i test;
- non e' adatto a restituire codici WMS se la confidenza e' bassa.
La regola semantica decisa e':
```text
meglio "udc non determinato" che un codice sbagliato
```
## Decisione
Testare Tesseract come alternativa piu' leggera e piu' controllabile per OCR numerico.
La logica prevista:
- whitelist solo cifre `0123456789`;
- piu' preprocess OpenCV;
- consenso tra varianti;
- soglia di confidenza;
- fallback `udc non determinato` se il risultato e' assente o ambiguo.
## Implementazione fatta
In `flywms_wms_server.py`:
- aggiunto supporto a `ocr_mode = tesseract`;
- aggiunto uso opzionale di `pytesseract`;
- aggiunto controllo preventivo del binario `tesseract.exe`;
- aggiunti preprocess:
- grayscale;
- sharpen;
- Otsu threshold;
- adaptive threshold;
- Otsu invertito;
- aggiunta logica di consenso;
- se Tesseract manca, il server non resta appeso.
## Ambiente
Wrapper Python installato:
```text
pytesseract
```
Binario non trovato:
```text
tesseract.exe
```
Verifica:
```text
Tesseract fallback: tesseract.exe non trovato nel PATH
OcrServerResult(text='udc non determinato', raw_text='', confidence=0.0, backend='tesseract-missing', fallback_used=True)
```
## Prossimi passi
1. Installare Tesseract per Windows.
2. Verificare se `tesseract.exe` entra nel PATH.
3. Se non entra nel PATH, avviare il server con:
```powershell
python flywms_wms_server.py --ocr-mode tesseract --tesseract-cmd "C:\Program Files\Tesseract-OCR\tesseract.exe"
```
4. Testare sui crop etichetta gia' salvati.
5. Se resta fragile, valutare una mini rete neurale dedicata ai 10 caratteri numerici.
## Nota
Il file `flywms_navigation.ini` non e' ancora stato aggiornato con la voce `wms_tesseract_cmd` perche' in questa fase il patch tool non e' riuscito a scriverlo. Il server ha comunque default interni funzionanti.

View File

@@ -0,0 +1,69 @@
# Aggiornamento 2026-05-16 19:46
## Valutazione YOLO digits come OCR alternativo
Abbiamo analizzato il progetto `thawro/yolov8-digits-detection`:
- repository: https://github.com/thawro/yolov8-digits-detection
- modello ONNX disponibile: https://huggingface.co/spaces/thawro/yolov8-digits-detection/tree/main/models
L'idea e' usare YOLO non per trovare il gaylord o l'etichetta, ma per leggere le cifre dentro il crop etichetta:
1. il client `flywms_navigation.py` individua gaylord ed etichetta come oggi;
2. il server WMS riceve il crop dell'etichetta;
3. il server passa il crop a un detector YOLO digits;
4. il detector restituisce bbox e classe delle singole cifre;
5. il server ordina le cifre da sinistra a destra;
6. il codice UDC finale e' la concatenazione delle cifre riconosciute;
7. se la sequenza non e' valida, il server deve rispondere `udc non determinato`.
## Considerazioni tecniche
Il progetto dichiara un dataset basato su cifre manoscritte HWD+, ma il demo video mostra anche riconoscimento su caratteri stampati. Questo significa che non va escluso a priori: va provato sui nostri crop reali, perche' potrebbe generalizzare abbastanza bene sulle etichette del video.
Vantaggi:
- modello piccolo: `yolo.onnx` e' circa 12 MB;
- potenzialmente molto piu' leggero di EasyOCR;
- puo' evitare il caricamento di EasyOCR/PyTorch nel server WMS;
- architettura adatta al nostro caso, perche' il codice UDC e' composto da cifre;
- output interpretabile: bbox, confidenza e classe di ogni cifra.
Rischi:
- il dominio di training non e' identico alle nostre etichette industriali;
- alcune cifre critiche, ad esempio 8 e 2, potrebbero comunque essere confuse;
- serve una fase di validazione su immagini reali delle nostre etichette;
- la licenza del repository/modello deve essere verificata prima di un uso oltre la demo;
- il modello riconosce singole cifre, quindi dovremo implementare noi ordinamento, filtri e validazione.
## Proposta di integrazione
Non conviene importare tutto il progetto come package. Conviene invece:
- scaricare/posizionare il modello `yolo.onnx` in una cartella locale, ad esempio `models/yolo_digits/yolo.onnx`;
- aggiungere una modalita' OCR nel server:
```ini
wms_ocr_mode = yolo_digits
wms_yolo_digits_model = models/yolo_digits/yolo.onnx
wms_yolo_digits_conf = 0.35
wms_yolo_digits_expected_len = 6
```
- implementare nel server una classe tipo `YoloDigitsOcr`;
- usare `onnxruntime` oppure OpenCV DNN per l'inferenza;
- restituire sempre un risultato conservativo:
- codice numerico solo se la sequenza supera i controlli minimi;
- `udc non determinato` in caso di ambiguita', crop tagliato o confidenza insufficiente.
## Decisione pratica
La strada e' promettente e probabilmente piu' coerente del generico OCR classico. La prova corretta da fare e':
1. integrare il modello come modalita' sperimentale separata;
2. non sostituire ancora EasyOCR/Tesseract;
3. eseguire test sui crop etichetta salvati dal nostro server;
4. confrontare errori e tempi rispetto a EasyOCR e Tesseract;
5. decidere se basta il modello esistente oppure serve fine-tuning su nostre etichette.

View File

@@ -0,0 +1,25 @@
# Aggiornamento 2026-05-16 19:52
## Nuovo laboratorio YOLO OCR
E' stata creata la cartella separata:
```text
C:\devel\yolo-ocr
```
Scopo della cartella:
- lavorare in modo pulito sull'ipotesi YOLO digits OCR;
- evitare di sporcare subito `flywms_navigation.py` e `flywms_wms_server.py`;
- provare il modello `thawro/yolov8-digits-detection` sui crop reali delle etichette;
- misurare accuratezza e tempi di inferenza;
- preparare eventualmente una pipeline di addestramento/fine-tuning con immagini nostre;
- trasferire in FlyWMS solo una soluzione gia' validata.
Decisione tecnica:
- `C:\devel\flywms` resta il progetto principale;
- `C:\devel\yolo-ocr` diventa il laboratorio OCR isolato;
- le prossime operazioni relative a YOLO digits OCR dovranno usare `C:\devel\yolo-ocr` come directory di lavoro.

View File

@@ -0,0 +1,94 @@
# Aggiornamento 2026-05-16 20:12
## Primo test YOLO digits su crop etichetta
E' stato scaricato il progetto:
```text
C:\devel\yolo-ocr\external\yolov8-digits-detection
```
Sono state trovate 28 immagini di test in:
```text
C:\devel\yolo-ocr\immagini_test
```
Nota: la cartella indicata verbalmente come `C:\yolo-ocr\immagini_test` non esisteva; le immagini erano nella cartella corretta `C:\devel\yolo-ocr\immagini_test`.
## Runner creato
E' stato creato:
```text
C:\devel\yolo-ocr\tools\batch_yolo_digits.py
```
Il runner:
- carica i modelli ONNX del progetto;
- non installa PyTorch;
- evita `pytorch_lightning`, usato dal progetto solo per logging;
- esegue inferenza CPU;
- ordina le cifre rilevate da sinistra a destra;
- salva un CSV con codice, numero cifre, confidenza media e tempo;
- salva immagini annotate con bbox delle cifre;
- salva una tavola di controllo visivo.
## Risultato con soglia 0.25
Output:
```text
immagini=28
codici_numerici=24
load_sec=2.679
infer_ms_medio=52.7
conf=0.25
iou=0.7
```
Il modello e' veloce: circa 53 ms per crop su CPU.
Qualita':
- riconosce davvero alcune cifre stampate;
- quindi la pista YOLO digits e' concreta;
- tuttavia spesso riconosce solo una parte del codice;
- alcuni crop restituiscono 1-3 cifre invece del codice completo;
- il modello out-of-the-box non e' ancora sufficiente per lettura affidabile UDC.
## Risultato con soglia 0.10
Output:
```text
immagini=28
codici_numerici=27
load_sec=1.881
infer_ms_medio=61.5
conf=0.1
iou=0.7
```
La soglia piu' bassa fa trovare piu' cifre, ma introduce duplicati e classi sbagliate. Quindi il problema non si risolve solo abbassando la soglia.
## Conclusione
Il modello esistente e' utile come base sperimentale, ma non e' pronto da integrare nel WMS come OCR affidabile.
La cosa positiva e' che:
- il modello e' leggero;
- gira senza PyTorch;
- riconosce cifre stampate almeno parzialmente;
- la velocita' e' adeguata;
- puo' diventare molto interessante con fine-tuning su immagini reali delle nostre etichette.
Prossimo passo consigliato:
1. migliorare preprocessing e crop locale, cercando di isolare meglio la riga numerica;
2. creare una piccola tabella ground truth per le 28 immagini;
3. misurare accuracy reale cifra per cifra e codice intero;
4. valutare fine-tuning del modello con immagini nostre annotate.

View File

@@ -0,0 +1,29 @@
# Aggiornamento 2026-05-17 09:30
## Decisione: passare al fine-tuning YOLO digits
Dopo il primo test del modello `thawro/yolov8-digits-detection` sui crop reali, la direzione scelta e' passare direttamente al fine-tuning.
Motivazione:
- il modello base riconosce alcune cifre stampate;
- la velocita' CPU e' adeguata;
- l'accuratezza out-of-the-box non e' sufficiente;
- i nostri codici UDC hanno dominio visivo specifico: font, stampa, prospettiva, luce, sfocatura, tagli parziali;
- un OCR generico resta fragile, mentre un detector addestrato sulle nostre etichette puo' migliorare molto.
Percorso previsto:
1. raccogliere crop reali delle etichette;
2. annotare ogni cifra con bbox e classe `0..9`;
3. creare dataset YOLO con split `train/val/test`;
4. fare training/fine-tuning YOLOv8 detection;
5. esportare il modello in ONNX;
6. testare il modello nel laboratorio `C:\devel\yolo-ocr`;
7. integrare nel server WMS solo quando il risultato e' affidabile.
Decisione architetturale:
- il fine-tuning resta nel laboratorio `C:\devel\yolo-ocr`;
- FlyWMS non viene toccato finche' il modello OCR non e' validato.

View File

@@ -0,0 +1,53 @@
# Aggiornamento 2026-05-17 09:39
## Preparazione dataset per fine-tuning YOLO OCR
E' stata creata la struttura dataset nel laboratorio:
```text
C:\devel\yolo-ocr\dataset
```
Cartelle principali:
```text
dataset\images\to_annotate
dataset\images\train
dataset\images\val
dataset\images\test
dataset\labels\to_annotate
dataset\labels\train
dataset\labels\val
dataset\labels\test
dataset\annotations_raw
dataset\manifests
```
Sono state copiate 28 immagini in:
```text
C:\devel\yolo-ocr\dataset\images\to_annotate
```
E' stato creato:
```text
C:\devel\yolo-ocr\dataset\data.yaml
C:\devel\yolo-ocr\dataset\README_ANNOTAZIONE.md
```
Regola di annotazione:
- annotare ogni singola cifra del codice UDC;
- usare classi `0..9`;
- non annotare l'intera etichetta;
- non annotare testi, righe o loghi;
- non annotare cifre troppo ambigue.
Valutazione quantita' dati:
- le 28 immagini attuali sono poche;
- possono bastare per una demo controllata o per un primo proof-of-concept;
- non bastano per una soluzione robusta da campo;
- per robustezza reale servira' arrivare a centinaia di crop, includendo casi difficili.

View File

@@ -0,0 +1,18 @@
# Aggiornamento 2026-05-17 10:08
## Configurazione Label Studio per cifre UDC
E' stato creato il file XML di configurazione Label Studio:
```text
C:\devel\yolo-ocr\dataset\label_studio_digits_config.xml
```
Contiene dieci classi rettangolari:
```text
0 1 2 3 4 5 6 7 8 9
```
Questa configurazione serve ad annotare ogni singola cifra del codice UDC con un bbox rettangolare standard, compatibile con export YOLO detection.

View File

@@ -0,0 +1,20 @@
# Aggiornamento 2026-05-17 10:37
## Avvio fase fine-tuning YOLO OCR
L'utente ha completato una prima sessione di tagging in Label Studio e ha chiesto di usare le immagini annotate per lanciare il fine-tuning.
Verifiche iniziali:
- le immagini sono presenti in `C:\devel\yolo-ocr\dataset\images\to_annotate`;
- non risultano ancora file YOLO `.txt` in `dataset\labels\to_annotate`;
- non risultano export Label Studio nuovi nella cartella export standard;
- probabilmente le annotazioni sono ancora nel database SQLite interno di Label Studio.
Prossimo passo:
- leggere le annotazioni dal database Label Studio;
- convertirle in formato YOLO;
- creare split `train/val/test`;
- avviare un primo training dimostrativo.

View File

@@ -0,0 +1,82 @@
# Aggiornamento 2026-05-17 11:21
## Fine-tuning YOLO OCR completato
Sono state convertite le annotazioni Label Studio del progetto:
```text
project_id=4
nome=addestramento ocr
```
Dataset YOLO creato:
```text
C:\devel\yolo-ocr\dataset
```
Split:
```text
images=26
boxes=156
train=19
val=5
test=2
```
Training lanciato inizialmente su GPU, ma fallito per incompatibilita':
```text
GPU: NVIDIA GeForce GTX 1050
problema: PyTorch installato non supporta compute capability sm_61
errore: CUDA error: no kernel image is available for execution on the device
```
Training rilanciato e completato su CPU:
```powershell
yolo detect train data=C:\devel\yolo-ocr\dataset\data.yaml model=yolov8n.pt epochs=60 imgsz=640 batch=4 workers=0 device=cpu project=C:\devel\yolo-ocr\training_runs name=digits_yolov8n_demo_cpu patience=15 seed=42
```
Risultati finali su validation:
```text
precision: 0.603
recall: 0.556
mAP50: 0.753
mAP50-95: 0.582
```
Risultati su test:
```text
images: 2
boxes: 12
precision: 0.535
recall: 0.771
mAP50: 0.752
mAP50-95: 0.488
```
Artefatti stabili copiati in:
```text
C:\devel\yolo-ocr\models\udc_digits_yolov8n_demo.pt
C:\devel\yolo-ocr\models\udc_digits_yolov8n_demo.onnx
```
Riepilogo tecnico:
```text
C:\devel\yolo-ocr\TRAINING_SUMMARY.md
```
Interpretazione:
- il fine-tuning funziona;
- il modello addestrato e' molto piu' promettente del modello generico provato ieri;
- l'export ONNX funziona;
- il dataset e' pero' ancora troppo piccolo;
- la metrica mAP e' incoraggiante, ma ora serve misurare la lettura del codice intero, perche' per il WMS una sola cifra sbagliata rende errato tutto il codice UDC.

View File

@@ -0,0 +1,46 @@
# Aggiornamento 2026-05-17 14:48
## Installazione RTX 3050 e verifica stack GPU
Nuova GPU rilevata:
```text
NVIDIA GeForce RTX 3050
VRAM: 6144 MiB
Driver: 596.36
CUDA driver runtime: 13.2
```
PyTorch vede correttamente la GPU:
```text
torch: 2.11.0+cu128
cuda_available: True
device_count: 1
device_name: NVIDIA GeForce RTX 3050
capability: (8, 6)
```
Questo risolve il problema avuto con la GTX 1050, che non era compatibile con il build PyTorch CUDA installato.
Modifica effettuata:
```text
flywms_navigation.ini
ultralytics_device = 0
```
Situazione stack:
- PyTorch/Ultralytics: GPU utilizzabile;
- OpenCV: attualmente `GUI: NONE`, quindi va ripristinito `opencv-python` non headless;
- ONNX Runtime: attualmente solo CPU provider, ma per il modello OCR piccolo puo' bastare CPU;
- Label Studio ha installato `opencv-python-headless`, causando la perdita delle finestre OpenCV.
Prossime azioni consigliate:
1. ripristinare OpenCV GUI;
2. fare benchmark FlyWMS con `ultralytics_device = 0`;
3. rilanciare training/fine-tuning OCR su GPU se servono nuovi modelli;
4. valutare ONNX Runtime GPU solo se l'OCR ONNX diventa un collo di bottiglia.

View File

@@ -0,0 +1,52 @@
# Aggiornamento 2026-05-17 14:51
## Diagnosi rallentamento avvio programmi
Sintomo:
- i programmi partono molto lentamente;
- non compaiono errori evidenti;
- anche il comando minimale `python -c "print('ok')"` impiega circa 4 secondi;
- import pesanti come `torch` restano molto lenti.
Indizi trovati:
```text
git-lfs attivo in piu' processi
MsMpEng / Microsoft Defender attivo
pCloud attivo
SearchIndexer attivo
testhd2.mp4 modificato come file LFS
```
Processi `git-lfs` osservati:
```text
git-lfs 10916
git-lfs 13380
git-lfs 15520
git-lfs 17104
```
Ipotesi piu' probabile:
- Git LFS sta lavorando/scansionando file grandi, in particolare video;
- Defender e/o pCloud stanno scansionando o sincronizzando gli stessi file;
- questo crea pressione su disco/I/O;
- l'avvio di Python, PyTorch, Label Studio e altri programmi diventa molto lento anche senza errori espliciti.
Altri punti:
- alcuni comandi diagnostici Windows richiedono privilegi amministrativi e sono stati negati;
- `opencv-python-headless` resta installato e OpenCV risulta `GUI: NONE`;
- questo problema OpenCV e' separato dal rallentamento generale, ma va sistemato.
Azioni consigliate:
1. fermare i processi `git-lfs` appesi;
2. evitare che pCloud sincronizzi `C:\devel`;
3. aggiungere esclusioni Defender per `C:\devel` e per le cartelle Python/progetto;
4. gestire i video grandi fuori dal repository o confermare intenzionalmente le modifiche LFS;
5. ripristinare OpenCV GUI;
6. creare ambienti Python separati invece di continuare a installare tutto nel Python globale.

View File

@@ -0,0 +1,75 @@
# Aggiornamento 2026-05-17 20:36
## Test accuratezza codice intero YOLO OCR
E' stato creato il test:
```text
C:\devel\yolo-ocr\tools\evaluate_code_accuracy.py
```
Scopo:
- usare il modello fine-tuned;
- rilevare le cifre su ogni crop;
- ordinare i bbox da sinistra a destra;
- ricostruire il codice UDC;
- confrontarlo con la ground truth derivata dalle label YOLO;
- produrre CSV e immagini annotate.
Primo test:
```powershell
python tools\evaluate_code_accuracy.py --split test --device 0 --conf 0.25 --iou 0.50 --output outputs\code_eval_test
python tools\evaluate_code_accuracy.py --split all --device 0 --conf 0.25 --iou 0.50 --output outputs\code_eval_all
```
Risultati:
```text
split=test
images=2
code_ok=0
code_accuracy=0.0000
char_accuracy=0.5385
```
```text
split=all
images=26
code_ok=0
code_accuracy=0.0000
char_accuracy=0.5389
```
Esempi:
```text
expected=187184 predicted=1871182
expected=182368 predicted=18288
```
Sono state provate anche soglie diverse:
```text
conf=0.40 iou=0.40 -> code_accuracy=0.0000 char_accuracy=0.5316
conf=0.50 iou=0.40 -> code_accuracy=0.0385 char_accuracy=0.4872
conf=0.60 iou=0.40 -> code_accuracy=0.0000 char_accuracy=0.4487
```
Conclusione:
- il fine-tuning ha migliorato molto la capacita' di rilevare bbox cifra rispetto al modello generico;
- pero' la lettura del codice intero non e' ancora affidabile;
- gli errori principali sono duplicazioni, cifre mancanti e confusione nelle ultime cifre;
- il prefisso `182` / `187` viene spesso agganciato, ma non basta per il WMS;
- non conviene integrare ancora il modello in FlyWMS come OCR operativo.
Prossimi passi consigliati:
1. aumentare dataset annotato;
2. introdurre post-processing specifico per codice a 6 cifre;
3. valutare crop piu' stretto della sola riga numerica;
4. usare metriche di codice intero come criterio principale;
5. integrare in FlyWMS solo come modalita' sperimentale quando il codice intero migliora.

View File

@@ -0,0 +1,166 @@
# Aggiornamento 2026-05-17 20:57
## Obiettivo
Provare la strada del dataset aumentato per il riconoscimento OCR numerico via YOLO, senza integrare ancora nulla in `flywms`.
## Dataset aumentato
Creato lo script:
```text
C:\devel\yolo-ocr\tools\augment_yolo_dataset.py
```
Generato il dataset:
```text
C:\devel\yolo-ocr\dataset_augmented
```
Composizione:
```text
train: 247 immagini, 1482 bbox
val: 5 immagini, 30 bbox
test: 2 immagini, 12 bbox
```
Nota importante: l'aumento dati e' applicato solo al train. Validation e test restano immagini originali, quindi sono utili per misurare se il modello generalizza.
Augmentation applicate:
- piccole rotazioni;
- piccole traslazioni;
- scala leggera;
- contrasto/luminosita';
- CLAHE;
- sharpening;
- blur leggero;
- rumore;
- compressione JPEG simulata.
## Training GPU
Addestramento eseguito sulla RTX 3050:
```powershell
yolo detect train data=C:\devel\yolo-ocr\dataset_augmented\data.yaml model=yolov8n.pt epochs=120 imgsz=640 batch=16 workers=0 device=0 project=C:\devel\yolo-ocr\training_runs name=digits_yolov8n_aug_gpu patience=30 seed=42
```
Il training e' terminato con early stopping:
```text
Best epoch: 11
Epoch completate: 41
Durata: circa 0.098 ore
```
Metriche YOLO finali su validation:
```text
precision: 0.826
recall: 0.873
mAP50: 0.990
mAP50-95: 0.745
```
Velocita' indicativa:
```text
preprocess: 0.6 ms
inference: 7.7 ms
postprocess: 4.1 ms
```
## Artefatti
Peso PyTorch:
```text
C:\devel\yolo-ocr\models\udc_digits_yolov8n_aug.pt
```
Export ONNX:
```text
C:\devel\yolo-ocr\models\udc_digits_yolov8n_aug.onnx
```
Run originale:
```text
C:\devel\yolo-ocr\training_runs\digits_yolov8n_aug_gpu
```
## Valutazione codice intero
Con soglia standard:
```powershell
python C:\devel\yolo-ocr\tools\evaluate_code_accuracy.py --model C:\devel\yolo-ocr\training_runs\digits_yolov8n_aug_gpu\weights\best.pt --dataset C:\devel\yolo-ocr\dataset_augmented --split test --device 0 --conf 0.25 --iou 0.50 --output C:\devel\yolo-ocr\outputs\code_eval_aug_test
```
Risultato su test:
```text
images=2
code_ok=0
code_accuracy=0.0000
char_accuracy=0.3333
```
Il problema era una cifra mancante nei due codici:
```text
187184 -> 17184
182368 -> 18268
```
Abbassando la soglia a `conf=0.15`, sui 2 test il codice completo diventa corretto:
```text
conf=0.15, iou=0.35..0.65
code_accuracy=1.0000
char_accuracy=1.0000
```
Pero' sulla validation originale, sempre con `conf=0.15`, il risultato resta insufficiente:
```text
images=5
code_ok=1
code_accuracy=0.2000
char_accuracy=0.7812
```
Esempi validation:
```text
187184 -> 187184 OK
182430 -> 18243601 NO
182519 -> 182610 NO
182511 -> 182611 NO
182242 -> 18224 NO
```
## Conclusione
La strada del dataset aumentato e' utile: le metriche YOLO sugli oggetti sono migliorate molto e la GPU funziona bene.
Pero' il problema reale non e' ancora risolto, perche' noi non dobbiamo solo rilevare cifre: dobbiamo ricostruire un codice numerico affidabile. Il modello ora e' sensibile alla soglia di confidenza e produce ancora cifre duplicate, mancanti o confuse.
Non conviene integrare questo OCR in `flywms` in questa forma.
## Prossimo passo tecnico
Prima di altro training, serve migliorare il post-processing del codice:
- soglia bassa controllata;
- ordinamento sinistra-destra;
- clustering/merge dei bbox vicini;
- vincolo codice a 6 cifre;
- gestione duplicati;
- report per capire quali cifre sono davvero ambigue.
Solo dopo questa fase ha senso decidere se aumentare ancora il dataset o cambiare architettura.

View File

@@ -0,0 +1,196 @@
# Aggiornamento 2026-05-18 14:18
## Obiettivo
Avviare la pista PaddleOCR come alternativa OCR tarabile sui crop etichetta, lasciando per ora da parte YOLO caratteri e senza integrare nulla in `flywms`.
## Decisione tecnica
Per PaddleOCR non usiamo le annotazioni bbox cifra-per-cifra.
Usiamo invece l'etichetta intera:
```text
crop_etichetta.jpg<TAB>codice_completo
```
Questo e' piu' coerente con l'obiettivo demo: dato un crop dell'etichetta, ottenere direttamente il codice UDC.
## Dataset creato
Creato script:
```text
C:\devel\yolo-ocr\tools\prepare_paddleocr_dataset.py
```
Generata cartella:
```text
C:\devel\yolo-ocr\paddleocr-recognizer
```
Dataset:
```text
C:\devel\yolo-ocr\paddleocr-recognizer\dataset
```
Conteggi:
```text
train: 475 immagini
val: 5 immagini
test: 2 immagini
```
Il train contiene varianti aumentate dell'intera etichetta. Validation e test restano crop originali.
File prodotti:
```text
dataset\train_list.txt
dataset\val_list.txt
dataset\test_list.txt
digit_dict.txt
preprocessed_preview\
README.md
README_OPERATIVO.md
```
## Preprocessing/augmentation
Lo script genera varianti controllate:
- upscale a altezza standard;
- contrasto/luminosita';
- CLAHE;
- sharpening;
- blur leggero;
- rumore;
- compressione JPEG;
- rotazione leggera.
Le preview dei preprocessing per validation/test sono in:
```text
C:\devel\yolo-ocr\paddleocr-recognizer\preprocessed_preview
```
## Baseline multi-pass
Creato script:
```text
C:\devel\yolo-ocr\tools\paddleocr_multipass_eval.py
```
Scopo:
- provare piu' preprocessing sullo stesso crop;
- passare ogni variante a PaddleOCR;
- tenere solo le cifre;
- preferire codici della lunghezza attesa;
- salvare CSV e immagini varianti.
## Blocco tecnico
PaddleOCR e' installato:
```text
paddleocr 3.5.0
paddlex 3.5.2
paddlepaddle 3.3.1
paddlepaddle-gpu 3.3.1
```
Ma l'esecuzione fallisce prima dell'OCR:
```text
OSError: [WinError 127] Impossibile trovare la procedura specificata.
Error loading C:\Python313\Lib\site-packages\paddle\..\nvidia\cudnn\bin\cudnn_cnn64_9.dll or one of its dependencies.
```
Ho provato ad aggiungere esplicitamente le cartelle `nvidia\*\bin` al loader DLL Windows nello script, ma non basta.
Il problema sembra nello stack Paddle GPU/CUDA/cuDNN globale, non nel dataset.
## Ambiente isolato
Per non modificare lo stack globale, creato un virtualenv dedicato:
```text
C:\devel\yolo-ocr\.venv-paddle-cpu
```
Installati nel virtualenv:
```text
paddlepaddle==3.3.1
paddleocr==3.5.0
opencv-python-headless
```
In questo ambiente PaddleOCR parte correttamente e usa i modelli gia' presenti in cache:
```text
PP-OCRv5_server_det
en_PP-OCRv5_mobile_rec
```
## Correzione parser
L'API PaddleOCR 3.5 restituisce i risultati in chiavi:
```text
rec_texts
rec_scores
```
Lo script `paddleocr_multipass_eval.py` e' stato corretto per leggere questo formato.
## Baseline OCR PaddleOCR
Validation:
```text
images=5
ok=5
accuracy=1.0000
```
Test:
```text
images=2
ok=2
accuracy=1.0000
```
File risultati:
```text
C:\devel\yolo-ocr\paddleocr-recognizer\outputs\multipass_val_venv_fixed\multipass_results.csv
C:\devel\yolo-ocr\paddleocr-recognizer\outputs\multipass_test_venv_fixed\multipass_results.csv
```
Esempi:
```text
182430 -> 182430
182242 -> 182242
182519 -> 182519
182511 -> 182511
187184 -> 187184
182368 -> 182368
```
## Conclusione
La pista PaddleOCR e' molto piu' promettente del previsto per il demo.
Il dataset e gli script sono pronti, e la baseline standard con preprocessing multi-pass legge correttamente tutti i crop disponibili in validation/test.
Il campione e' pero' molto piccolo, quindi non e' ancora una prova di robustezza.
Per il prossimo passo non farei subito fine-tuning: integrerei prima nel server WMS una modalita' OCR sperimentale basata su PaddleOCR multi-pass, con fallback `UDC_NON_DETERMINATO` quando non c'e' un candidato numerico credibile.

View File

@@ -0,0 +1,115 @@
# Aggiornamento 2026-05-18 14:39
## Obiettivo
Migliorare la pista PaddleOCR prima di pensare all'integrazione in `flywms`.
## Modifiche
Aggiornato:
```text
C:\devel\yolo-ocr\tools\paddleocr_multipass_eval.py
```
Migliorie introdotte:
- resize su piu' altezze (`64,96,128`);
- variante con bordo bianco per evitare tagli ai caratteri;
- CLAHE;
- sharpening;
- threshold adattivo;
- Otsu;
- Otsu + close morfologico;
- denoise;
- scelta finale a consenso.
La scelta finale ora non dipende solo dal massimo score PaddleOCR.
Il punteggio considera:
- codice letto da piu' varianti;
- numero di varianti distinte che producono lo stesso codice;
- bonus se il codice ha la lunghezza attesa;
- penalita' per codici troppo corti o troppo lunghi.
## Risultati configurazione estesa
Validation:
```text
images=5
ok=5
accuracy=1.0000
```
Test:
```text
images=2
ok=2
accuracy=1.0000
```
Output:
```text
C:\devel\yolo-ocr\paddleocr-recognizer\outputs\multipass_val_consensus
C:\devel\yolo-ocr\paddleocr-recognizer\outputs\multipass_test_consensus
```
La lettura corretta e' sostenuta da molti voti, quindi non sembra casuale:
```text
182430 -> 19 voti
182242 -> 12 voti
182519 -> 20 voti
182511 -> 18 voti
187184 -> 18/19 voti
182368 -> 14 voti
```
## Risultati configurazione leggera
Testata anche la configurazione con sola altezza `96`, piu' adatta al server:
```powershell
--target-heights 96
```
Validation:
```text
images=5
ok=5
accuracy=1.0000
```
Test:
```text
images=2
ok=2
accuracy=1.0000
```
Output:
```text
C:\devel\yolo-ocr\paddleocr-recognizer\outputs\multipass_val_h96
C:\devel\yolo-ocr\paddleocr-recognizer\outputs\multipass_test_h96
```
## Conclusione
Possiamo migliorare ancora raccogliendo nuove immagini reali, ma sul materiale attuale PaddleOCR standard con preprocessing multi-pass e consenso e' gia' solido per una demo.
La configurazione leggera `--target-heights 96` e' la candidata migliore per la prima integrazione sperimentale nel server WMS.
L'integrazione dovra' comunque mantenere un fallback:
```text
UDC_NON_DETERMINATO
```
quando non c'e' un candidato a 6 cifre con consenso sufficiente.

View File

@@ -0,0 +1,166 @@
# Aggiornamento 2026-05-18 14:58
## Obiettivo
Integrare PaddleOCR nel server WMS demo, senza modificare la GUI e senza integrare ancora una interfaccia DearPyGUI.
## Implementazione
Creato worker persistente:
```text
C:\devel\flywms\flywms_paddleocr_worker.py
```
Il worker gira con il virtualenv isolato:
```text
C:\devel\yolo-ocr\.venv-paddle-cpu\Scripts\python.exe
```
Il server WMS resta nel suo ambiente Python attuale e comunica col worker via stdin/stdout JSON. Questo evita il problema del Paddle GPU globale:
```text
WinError 127 su cudnn_cnn64_9.dll
```
## Server WMS
Aggiornato:
```text
C:\devel\flywms\flywms_wms_server.py
```
Nuovo backend OCR:
```text
--ocr-mode paddleocr
```
Backend disponibili:
```text
paddleocr
easyocr
tesseract
fake
```
Il worker PaddleOCR resta caricato tra richieste successive, quindi non ricarica il modello a ogni snapshot.
## Parametri INI
Aggiornato:
```text
C:\devel\flywms\flywms_navigation.ini
```
Nuovi parametri:
```text
wms_ocr_mode = paddleocr
wms_paddle_python = C:\devel\yolo-ocr\.venv-paddle-cpu\Scripts\python.exe
wms_paddle_worker_script = C:\devel\flywms\flywms_paddleocr_worker.py
wms_paddle_target_heights = 96
wms_paddle_variant_set = fast
wms_paddle_expected_digits = 6
wms_paddle_min_votes = 2
wms_paddle_min_confidence = 0.70
```
Aggiornati anche i timeout, perche' PaddleOCR CPU richiede piu' di 2 secondi:
```text
remote_ack_timeout_sec = 10.0
wms_timeout_sec = 12.0
```
## Logica OCR
La configurazione `fast` prova 3 varianti:
```text
originale
CLAHE
CLAHE + sharpening
```
Il codice viene accettato solo se:
- e' numerico;
- ha 6 cifre;
- ha almeno 2 voti tra le varianti;
- supera la confidenza minima.
In caso contrario il server restituisce:
```text
udc non determinato
```
## Test eseguiti
Compilazione:
```text
python -m py_compile flywms_wms_server.py flywms_paddleocr_worker.py
```
Test worker diretto:
```text
input: snapshot_0019_track_123_label_payload_orig.jpg
output: 182368
votes: 3
backend: paddleocr
```
Test server locale senza GUI:
```powershell
python C:\devel\flywms\flywms_wms_server.py --no-ui --port 8098 --received-dir C:\devel\flywms\wms_received_test --ocr-mode paddleocr --paddle-variant-set fast --fake-processing-sec 0
```
Richiesta 1:
```text
ocr_text: 182368
ocr_backend: paddleocr
ocr_fallback_used: false
ocr_votes: 3
processing_ms: circa 8500 ms
```
Richiesta 2 con worker gia' caldo:
```text
ocr_text: 187184
ocr_backend: paddleocr
ocr_fallback_used: false
ocr_votes: 3
processing_ms: circa 3200 ms
```
## Nota prestazionale
Il primo snapshot paga il caricamento del worker PaddleOCR. Gli snapshot successivi sono piu' veloci ma restano nell'ordine di qualche secondo su CPU.
Per il demo va bene, ma il timeout del client deve restare piu' alto del vecchio valore da 2 secondi.
## Prossimo passo
Avviare il server:
```powershell
python flywms_wms_server.py
```
Poi avviare la navigazione:
```powershell
python flywms_navigation.py --wms-enabled
```
Verificare nella finestra comandi che compaia il codice OCR restituito dal WMS.

View File

@@ -0,0 +1,50 @@
# Aggiornamento 2026-05-18 15:28
## Problema
Avviando:
```powershell
python flywms_wms_server.py
```
il server partiva, ma il thread UI OpenCV falliva:
```text
cv2.error: The function is not implemented
```
Causa: nel Python globale e' installata una build OpenCV headless, quindi `cv2.namedWindow` non e' disponibile.
## Correzione
Aggiornato:
```text
C:\devel\flywms\flywms_wms_server.py
```
Ora `server_ui_loop()` intercetta l'errore `cv2.error` su `namedWindow`, logga il problema e disabilita solo la UI OpenCV del server.
FastAPI e PaddleOCR restano attivi.
## Verifica
Avvio di test:
```powershell
python C:\devel\flywms\flywms_wms_server.py --port 8099
```
Risultato:
```text
UI OpenCV disabilitata: highgui non disponibile
Uvicorn running on http://0.0.0.0:8099
```
Il server non si chiude piu' per l'errore della finestra OpenCV.
## Nota
Quando sistemeremo OpenCV non-headless o passeremo a DearPyGUI per il server, la UI potra' essere riabilitata senza cambiare la parte WMS/OCR.

View File

@@ -0,0 +1,107 @@
# Aggiornamento 2026-05-18 15:42
## Obiettivo
Ripristinare OpenCV con supporto CUDA, come nel setup precedente basato sulle wheel `cudawarped/opencv-python-cuda-wheels`.
## Situazione iniziale
Dopo la reinstallazione per riavere le finestre OpenCV, lo stato era:
```text
OpenCV GUI: WIN32UI
OpenCV CUDA devices: 0
```
Quindi la GUI era tornata, ma CUDA no.
## Installazione wheel CUDA
Installata la wheel:
```text
https://github.com/cudawarped/opencv-python-cuda-wheels/releases/download/4.12.0.88/opencv_contrib_python-4.12.0.88-cp37-abi3-win_amd64.whl
```
Motivo della scelta:
- il sistema ha CUDA Toolkit `v12.9`;
- la release `4.12.0.88` e' compilata contro CUDA `12.9`;
- la RTX 3050 ha compute capability `(8, 6)`, supportata dalla wheel.
## Correzione config.py
Dopo l'installazione, `import cv2` falliva per DLL mancanti.
Aggiornato:
```text
C:\Python313\Lib\site-packages\cv2\config.py
```
Aggiunti i path alle DLL NVIDIA presenti in:
```text
C:\Python313\Lib\site-packages\nvidia\cudnn\bin
C:\Python313\Lib\site-packages\nvidia\cublas\bin
C:\Python313\Lib\site-packages\nvidia\cuda_runtime\bin
C:\Python313\Lib\site-packages\nvidia\cufft\bin
C:\Python313\Lib\site-packages\nvidia\curand\bin
C:\Python313\Lib\site-packages\nvidia\cusolver\bin
C:\Python313\Lib\site-packages\nvidia\cusparse\bin
C:\Python313\Lib\site-packages\nvidia\nvjitlink\bin
```
## Verifica OpenCV
Risultato:
```text
cv2 4.12.0
GUI: WIN32UI
NVIDIA CUDA: YES (ver 12.9.86, CUFFT CUBLAS NVCUVID NVCUVENC)
cuDNN: YES (ver 9.10.2)
cuda devices: 1
gpu resize: OK
namedWindow: OK
```
Quindi OpenCV ora ha sia GUI sia CUDA.
## Stato GPU complessivo
YOLO / Ultralytics:
```text
torch_cuda True
NVIDIA GeForce RTX 3050
```
OpenCV:
```text
CUDA attivo
cuda devices 1
```
PaddleOCR:
```text
paddle 3.3.1
cuda False
device cpu
```
PaddleOCR resta volutamente nel virtualenv CPU per evitare il problema precedente sulle DLL/cuDNN Paddle GPU.
## Nota operativa
Ora ha senso lavorare sui target:
```text
preview/acquisizione: 24 fps
YOLO: circa 14-15 fps
OCR/WMS: asincrono, non deve bloccare la preview
```
OpenCV CUDA non accelera automaticamente tutto il codice: serve usare esplicitamente funzioni `cv2.cuda.*` nei punti in cui conviene. Pero' ora la base CUDA e' disponibile.

View File

@@ -0,0 +1,82 @@
# Aggiornamento 2026-05-18 15:53
## Obiettivo
Portare subito le chiamate disponibili verso il percorso piu' veloce possibile, usando CUDA dove il costo di trasferimento CPU/GPU e' giustificato e attivando FP16 per YOLO su GPU.
## Modifiche
- Aggiunti helper OpenCV CUDA con fallback CPU:
- `opencv_cuda_available()`
- `cuda_resize(...)`
- `cuda_cvt_color(...)`
- Gli helper usano CUDA solo sopra una soglia minima di pixel, per evitare di rallentare crop piccoli con upload/download GPU.
- Applicati gli helper a:
- preview e resize in `flywms_navigation.py`
- preprocessing e preview in `flywms_wms_server.py`
- preprocessing nel worker `flywms_paddleocr_worker.py`
- conversione BGR/RGBA della GUI DearPyGUI in `flywms_navigation_gui.py`
- Aggiunto parametro `yolo_half` in `flywms_navigation.ini`.
- Aggiunti flag CLI:
- `--yolo-half`
- `--no-yolo-half`
- `UltralyticsDetector` passa `half=True` a `model.predict(...)` quando il device non e' CPU.
- Log iniziale aggiornato con:
- stato OpenCV CUDA
- stato YOLO half precision
## Verifiche
Compilazione:
```powershell
python -m py_compile flywms_navigation.py flywms_wms_server.py flywms_paddleocr_worker.py flywms_navigation_gui.py
```
OpenCV:
```text
cv2 4.12.0
cuda_devices 1
GUI: WIN32UI
NVIDIA CUDA: YES (ver 12.9.86, CUFFT CUBLAS NVCUVID NVCUVENC)
cuDNN: YES (ver 9.10.2)
```
Helper CUDA:
```text
opencv_cuda True (720, 1280, 3) (720, 1280)
```
Run headless breve:
```powershell
python flywms_navigation.py --no-display --max-frames 40
```
Risultato rilevante:
```text
OpenCV CUDA: attivo
YOLO half precision: attiva
```
Misura inferenza YOLO sullo stesso frame:
```text
iter 0 ms 8430.9
iter 1 ms 27.5
iter 2 ms 26.7
iter 3 ms 28.5
iter 4 ms 30.4
```
Il primo giro e' warm-up del modello. A regime YOLO e' circa 27-30 ms/frame sul frame di test, quindi compatibile con un target dimostrativo di 14-15 fps lato inferenza se la pipeline non introduce altri colli di bottiglia.
## Note tecniche
- OpenCV CUDA non accelera automaticamente tutte le chiamate: bisogna usare esplicitamente `cv2.cuda.*`.
- Per crop piccoli e preprocessing leggero puo' essere piu' veloce restare su CPU.
- PaddleOCR resta attualmente nel virtualenv CPU; gli helper nel worker fanno fallback se OpenCV CUDA non e' disponibile in quell'ambiente.
- La prossima ottimizzazione importante sara' evitare warm-up visibile all'avvio, eseguendo una inferenza iniziale prima del loop operativo.

View File

@@ -0,0 +1,43 @@
# Aggiornamento 2026-05-18 18:15
## Obiettivo
Rendere espliciti a video i diversi FPS della demo, separando:
- FPS nominali della sorgente video
- target preview
- FPS reali del loop
- target YOLO
- FPS reali di YOLO
## Modifiche
- In [flywms_navigation.py](C:/devel/flywms/flywms_navigation.py) ho aggiunto `format_fps_value(...)` per mostrare valori puliti o `n/d`.
- All'avvio della navigazione ora viene loggato:
- `FPS sorgente=...`
- `preview_target=...`
- `yolo_target=...`
- Nell'overlay della finestra `flywms navigate` ora compare una riga del tipo:
```text
frame=123 src_fps=30.0 preview_target=24.0 fps=9.8 yolo_target=15.0 yolo_fps=9.7 yolo=28ms det=2 labels=2 tracks=2 snap=0
```
- In [flywms_navigation_gui.py](C:/devel/flywms/flywms_navigation_gui.py) ho reso coerente lo stesso overlay.
- Nella GUI DearPyGUI ho anche riallineato il costruttore del detector al nuovo parametro `yolo_half`.
## Verifica
Compilazione:
```powershell
python -m py_compile flywms_navigation.py flywms_navigation_gui.py
```
Esempio di testo generato:
```text
frame=1 src_fps=30.0 preview_target=24.0 fps=1000.0 yolo_target=15.0 yolo_fps=0.0 yolo=0ms det=0 labels=0 tracks=0 snap=0
```
Il valore `fps=1000.0` in questo micro-test e' artificiale, perche' non deriva da un run reale ma da una costruzione istantanea della stringa. In esecuzione reale il valore utile e' quello mostrato live in overlay.

View File

@@ -0,0 +1,67 @@
# Aggiornamento 2026-05-18 18:30
## Obiettivo
Registrare in un file dedicato le tempistiche dettagliate della pipeline, riducendo allo stesso tempo il costo della preview principale spostando la telemetria fuori dall'overlay.
## Modifiche
- Aggiunta classe `PerfLogWriter` in [flywms_navigation.py](C:/devel/flywms/flywms_navigation.py) con:
- bufferizzazione in memoria
- flush periodico a tempo
- flush per numero di righe
- Nuovi parametri INI/CLI:
- `perf_log_path`
- `perf_log_flush_interval_sec`
- `perf_log_flush_lines`
- Il file di default e' `tempistiche.txt`.
- Per ogni frame vengono scritte colonne TSV con:
- `read_ms`
- `yolo_ms`
- `track_ms`
- `draw_ms`
- `ui_ms`
- `snapshot_pause_ms`
- `wms_wait_ms`
- `loop_ms`
- `loop_fps`
- `yolo_real_fps`
- conteggi oggetti/track/snapshot
- ultimo comando navigazione
- La finestra principale `flywms navigate` non disegna piu':
- riga FPS/metriche
- ultimo comando
- testo sopra i bbox dei gaylord
- testo sopra i bbox etichetta
- Restano nella preview principale:
- bbox
- centri
- fascia utile
- freccia di movimento simulato
- Aggiornata anche [flywms_navigation_gui.py](C:/devel/flywms/flywms_navigation_gui.py) per il nuovo renderer alleggerito e per il costruttore `UltralyticsDetector(..., yolo_half)`.
## Verifica
Compilazione:
```powershell
python -m py_compile flywms_navigation.py flywms_navigation_gui.py
```
Run di test:
```powershell
python flywms_navigation.py --no-display --max-frames 20 --perf-log-path tempistiche-test.txt
```
Prime righe del log:
```text
ts frame_id run_yolo read_ms yolo_ms track_ms draw_ms ui_ms snapshot_pause_ms wms_wait_ms loop_ms loop_fps yolo_real_fps src_fps preview_target yolo_target det labels tracks active snapshots command
2026-05-18 18:31:14 1 1 39.710 10065.399 0.106 0.000 0.000 0.000 0.000 10150.452 0.099 0.099 30.0 24.0 15.0 2 2 2 2 0 ""
2026-05-18 18:31:14 2 1 7.376 38.036 0.222 0.000 0.000 0.000 0.000 47.984 0.196 0.196 30.0 24.0 15.0 2 2 2 2 0 ""
```
## Nota
Nel test headless `draw_ms` e `ui_ms` sono giustamente nulli. Nel run reale con finestre aperte il file `tempistiche.txt` mostrera' il costo della parte grafica e delle pause simulate, che e' proprio quello che ci serve per capire dove si perde il target FPS.

View File

@@ -0,0 +1,58 @@
# Aggiornamento 2026-05-18 19:14
## Obiettivo
Preparare una versione benchmark della navigazione senza interfaccia, con cattura/preview target a 30 fps, per confrontare il costo della UI rispetto alla pipeline pura.
## Modifiche
- Aggiunto profilo `benchmark` in [flywms_navigation.py](C:/devel/flywms/flywms_navigation.py):
- `--benchmark-mode`
- `--benchmark-preview-fps`
- Aggiunte le controparti INI in [flywms_navigation.ini](C:/devel/flywms/flywms_navigation.ini):
- `benchmark_mode = false`
- `benchmark_preview_fps = 30.0`
- Quando `benchmark_mode` e' attivo:
- forza `no_display = true`
- forza `window_layout_enabled = false`
- forza `realtime_playback = true`
- forza `preview_fps = benchmark_preview_fps`
- se il log tempi e' quello di default, lo sposta su `tempistiche-benchmark.txt`
## Uso
```powershell
python flywms_navigation.py --benchmark-mode
```
Oppure, con file log esplicito:
```powershell
python flywms_navigation.py --benchmark-mode --perf-log-path tempistiche-benchmark.txt
```
## Verifica
Compilazione:
```powershell
python -m py_compile flywms_navigation.py
```
Run breve:
```powershell
python flywms_navigation.py --benchmark-mode --max-frames 40
```
Output iniziale verificato:
```text
Profilo benchmark attivo: no_display=true preview_fps=30.0 log=tempistiche-benchmark.txt
FPS sorgente=30.0 preview_target=30.0 yolo_target=15.0
Log tempistiche: C:\devel\flywms\tempistiche-benchmark.txt
```
## Nota
Nel run corto di test c'e' stato un warm-up molto pesante di YOLO/NMS al primo frame, quindi quel log non va ancora usato come benchmark finale. Serve un run completo o almeno piu' lungo per avere un confronto sensato con la demo.

View File

@@ -0,0 +1,97 @@
# Aggiornamento 2026-05-18 19:58
## Analisi benchmark
File analizzato:
- [tempistiche-benchmark.txt](C:/devel/flywms/tempistiche-benchmark.txt)
Nota metodologica:
- Il file conteneva due intestazioni (`ts ...`) perche' un run breve di verifica era stato appeso al run completo.
- L'analisi e' stata fatta filtrando solo le righe con `frame_id` numerico.
## Risultati principali
- Frame validi: `19789`
- Snapshot: `69`
- Video sorgente: `30.0 fps`
- Durata nominale video: circa `658.3 s` = `10.97 min`
Benchmark completo:
- `total_loop_s = 2004.19 s`
- `total_pause_s = 552.03 s`
- `total_wms_s = 690.03 s`
- `total_active_s = 762.13 s`
Interpretazione:
- Le pause snapshot headless valgono circa `8.0 s` ciascuna.
- L'attesa WMS simulata headless vale circa `10.0 s` per snapshot.
- Quindi il benchmark completo include ancora il comportamento demo di snapshot+attesa, solo senza finestre.
## Prestazioni nette della pipeline
Togliendo pause snapshot e attesa WMS:
- `active_fps = 25.97`
- `active_yolo_fps = 12.69`
Tempi medi in regime stabile:
- `mean_loop_ms = 36.90`
- `mean_read_ms = 6.10`
- `mean_yolo_ms = 31.20`
- `mean_track_ms = 0.18`
- `mean_draw_ms = 0.00`
- `mean_ui_ms = 0.00`
Percentili:
- `loop_p50 = 33.91 ms`
- `loop_p90 = 68.93 ms`
- `loop_p95 = 73.14 ms`
- `yolo_p50 = 27.85 ms`
- `yolo_p90 = 38.05 ms`
- `yolo_p95 = 46.10 ms`
## Confronto con la demo OpenCV
Run demo precedente:
- `demo_active_s = 1092.85 s`
- `demo_draw_ui_s = 597.18 s`
Run benchmark:
- `benchmark_active_s = 762.13 s`
- `benchmark_draw_ui_s = 0`
Risparmio netto:
- `active_s_saved = 330.72 s` circa `5.51 min`
## Conclusione
La UI OpenCV pesava davvero molto, ma non era l'unico problema.
Tolte completamente le finestre:
- la pipeline netta scende a circa `12.70 min`
- il video reale dura circa `10.97 min`
Residuo rispetto all'obiettivo netto:
- circa `103.8 s` = `1.73 min`
Questa parte residua dipende principalmente da:
- inferenza YOLO ancora sotto il target di `15 fps`
- costo di `cap.read()`
- scheduling single-thread del loop
Quindi:
- la demo con interfaccia va ripensata per non ricadere nel costo UI attuale
- la pipeline core, senza UI, e' ormai abbastanza vicina all'obiettivo ma non ancora coincidente con la durata del video

View File

@@ -0,0 +1,86 @@
# Aggiornamento 2026-05-29 16:38
## Step 1 - Core / Observer
Completati:
- documento di progetto Step 1 in [step1_core_observer_design.md](/C:/devel/flywms/step1_core_observer_design.md)
- separazione iniziale tra core runtime e observer esterno
- configurazione observer aggiunta in [flywms_navigation.ini](/C:/devel/flywms/flywms_navigation.ini)
- nuovo entrypoint observer in [flywms_navigation_observer.py](/C:/devel/flywms/flywms_navigation_observer.py)
## Modifiche implementate
In [flywms_navigation.py](/C:/devel/flywms/flywms_navigation.py):
- aggiunto `ObserverPublisher`
- il core puo' pubblicare su `localhost`:
- telemetria strutturata
- preview JPEG a bassa frequenza
- quando `observer_enabled = true`:
- la UI locale integrata viene disattivata
- il core non aspetta l'observer
- l'observer puo' essere assente o disconnettersi senza bloccare la pipeline
- la sequenza snapshot headless e' stata resa osservabile per fasi:
- `move`
- `stabilize`
- `capture`
- `return`
- `wait_wms`
In [flywms_navigation_observer.py](/C:/devel/flywms/flywms_navigation_observer.py):
- processo separato
- connessione TCP locale al core
- visualizzazione di:
- preview navigazione
- pannello comandi/stato
- snapshot
- crop etichetta
## Verifiche eseguite
1. Compilazione Python:
```bash
python -m py_compile flywms_navigation.py flywms_navigation_observer.py
```
Esito: ok.
2. Smoke test core con observer attivo ma non collegato:
```bash
python flywms_navigation.py --video testhd2_edit.mp4 --observer-enabled --max-frames 30
```
Esito:
- il core parte regolarmente
- la UI locale viene disattivata come previsto
- il publisher si mette in ascolto su `127.0.0.1:8765`
- il run chiude senza blocchi anche senza observer collegato
## Stato attuale
Lo Step 1 e' implementato nella sua forma iniziale e il lato critico del core e' verificato.
Resta da fare una verifica manuale della GUI observer in esecuzione reale, cioe':
1. avviare `flywms_navigation_observer.py`
2. avviare il core con `--observer-enabled`
3. controllare fluidita', frequenza preview e correttezza dei pannelli
## Comandi di avvio previsti
Observer:
```bash
python flywms_navigation_observer.py
```
Core:
```bash
python flywms_navigation.py --video testhd2_edit.mp4 --observer-enabled
```

View File

@@ -0,0 +1,75 @@
# Aggiornamento 2026-05-30 18:12
## Step 2 - Capture / Inference
Completati:
- documento di progetto Step 2 in [step2_capture_inference_design.md](/C:/devel/flywms/step2_capture_inference_design.md)
- introduzione del pacchetto `CapturedFrame`
- introduzione del `CaptureWorker`
- propagazione della posa congelata dal capture fino allo snapshot
## Modifiche implementate
In [flywms_navigation.py](/C:/devel/flywms/flywms_navigation.py):
- aggiunto `CapturedFrame` con:
- `frame_id`
- `timestamp`
- `video_time_sec`
- `frame`
- `pose`
- `read_ms`
- aggiunto `sample_demo_pose(...)`
- aggiunto `CaptureWorker` con coda `maxsize=1`
- politica di coda: `latest frame wins`
- `cap.read()` spostato fuori dal loop principale
- `NavigationController.process_track(...)` ora consuma `CapturedFrame`
- `CandidateSnapshot` e `NavigationSnapshot` ora portano anche `capture_pose`
- metadata snapshot JSON aggiornati: `drone_pose_simulated` ora deriva dal frame catturato
## Correzione importante sulle metriche
Con `latest frame wins`, il `frame_id` della sorgente puo' saltare. Per questo:
- `frame_id` resta l'identificativo del frame sorgente;
- `processed_frames` misura invece quanti frame il core ha davvero consumato.
Le metriche `loop_fps` e i log prestazionali ora usano `processed_frames`, non `frame_id`.
## Verifiche eseguite
1. Compilazione Python:
```bash
python -m py_compile flywms_navigation.py flywms_navigation_observer.py
```
Esito: ok.
2. Smoke test core senza observer:
```bash
python flywms_navigation.py --video testhd2_edit.mp4 --max-frames 30
```
Esito: ok.
3. Smoke test core con observer attivo:
```bash
python flywms_navigation.py --video testhd2_edit.mp4 --observer-enabled --max-frames 30
```
Esito: ok.
## Nota operativa
Dopo lo Step 2, `--max-frames` resta un limite sui frame della sorgente video, non sui frame effettivamente processati.
Questo va bene per smoke test rapidi, ma non e' il modo corretto per giudicare la resa finale della pipeline realtime. Il benchmark successivo va fatto sulla durata completa del video, confrontando:
- durata nominale video;
- durata effettiva demo;
- `processed_frames`;
- `yolo_fps` reale.

View File

@@ -0,0 +1,73 @@
# Aggiornamento 2026-05-30 19:08
## Step 3 - Inferenza adattiva
Documentazione:
- [step3_adaptive_inference_design.md](/C:/devel/flywms/step3_adaptive_inference_design.md)
Implementato:
- scheduler YOLO adattivo con tre stati:
- `idle`
- `tracking`
- `critical`
- parametri configurabili:
- `adaptive_yolo_enabled`
- `idle_yolo_fps`
- `tracking_yolo_fps`
- `critical_yolo_fps`
- telemetria observer aggiornata con `yolo_mode`
## Esito benchmark
Run completo con:
- `adaptive_yolo_enabled = true`
- `idle = 8`
- `tracking = 12`
- `critical = 15`
Risultato finale:
- durata demo totale: `874.73 s`
- durata demo netta senza WMS: `796.71 s`
- durata video: `658.14 s`
- scostamento netto: `+138.57 s` = `+21.1%`
Confronto con run precedente senza adattivo:
- netto precedente: `756.18 s`
- netto con adattivo: `796.71 s`
- peggioramento: circa `+40.5 s`
## Interpretazione
La riduzione della frequenza YOLO nei tratti idle non ha compensato l'effetto collaterale introdotto sul comportamento del tracking e della logica snapshot.
In pratica:
- il costo medio YOLO e' sceso;
- ma il comportamento globale della missione e' peggiorato;
- quindi questa prima forma di inferenza adattiva non e' vantaggiosa.
## Decisione
La feature resta disponibile nel codice come opzione sperimentale, ma viene disattivata di default:
- [flywms_navigation.py](/C:/devel/flywms/flywms_navigation.py)
- [flywms_navigation.ini](/C:/devel/flywms/flywms_navigation.ini)
Default attuale:
- `adaptive_yolo_enabled = false`
## Conclusione
Lo Step 3, nella forma provata oggi, non va adottato come ottimizzazione di default.
Se si vorra' tornare su questa strada, servira' una strategia piu' mirata, ad esempio:
- adattamento solo in assenza totale di target per un certo tempo;
- nessuna riduzione di frequenza quando esistono track vive;
- oppure un criterio basato su zone del video predefinite, non solo sullo stato istantaneo delle track.

View File

@@ -0,0 +1,61 @@
## Aggiornamento 2026-06-03 21:10
### Obiettivo
Introdurre una UI DearPyGUI solo per i componenti esterni:
- observer di `flywms_navigation`
- server `flywms_wms_server`
Lasciare invariata la parte intelligente del core:
- acquisizione
- inferenza
- tracking
- logica di navigazione e snapshot
### Baseline salvata
- Commit locale: `e86c05a`
- Tag locale: `gui-observer-in-opencv`
Nota: il push su Gitea non e' riuscito per problema di risoluzione DNS dell'host remoto.
### Documentazione aggiunta
- `dearpygui_observer_server_spec.md`
### Modifiche implementate
#### `flywms_navigation_observer.py`
- aggiunto supporto backend UI:
- `dearpygui`
- `opencv`
- `auto`
- mantenuto il protocollo socket esistente con il core
- aggiunta UI DearPyGUI con:
- preview principale navigazione
- preview snapshot
- preview crop etichetta
- pannello stato
- pannello metriche
- pannello comandi
- mantenuto fallback OpenCV
#### `flywms_wms_server.py`
- aggiunto supporto backend UI:
- `dearpygui`
- `opencv`
- `auto`
- lasciata invariata la logica FastAPI/OCR/ACK
- aggiunta UI DearPyGUI con:
- immagine ricevuta
- stato server
- payload OCR / WMS
- mantenuto fallback OpenCV
### Verifiche eseguite
- `python -m py_compile flywms_navigation_observer.py flywms_wms_server.py`
- import e selezione backend:
- `flywms_navigation_observer.choose_backend('auto') -> dearpygui`
- `flywms_wms_server.choose_ui_backend('auto') -> dearpygui`
### Stato attuale
- core intelligente invariato
- observer e server pronti per prova con DearPyGUI
- fallback OpenCV ancora disponibile per debug

View File

@@ -0,0 +1,103 @@
# DearPyGUI UI Spec - Observer e WMS Server
## Scopo
Rendere la demo piu' ordinata e leggibile spostando la presentazione visuale da OpenCV a DearPyGUI nei soli componenti esterni:
- `flywms_navigation_observer.py`
- `flywms_wms_server.py`
La parte intelligente resta invariata:
- `flywms_navigation.py`
- tracking
- snapshot logic
- WMS client
- logica OCR / FastAPI lato server
## Principio architetturale
La UI non entra nel percorso critico.
### Observer
- riceve gia' telemetria e preview dal core;
- DearPyGUI sostituisce solo le finestre OpenCV;
- il protocollo socket e il payload restano invariati.
### WMS Server
- continua a ricevere upload e a fare OCR come oggi;
- DearPyGUI sostituisce solo le finestre OpenCV di monitoraggio;
- nessuna modifica al contratto HTTP.
## Layout Observer
### Colonna sinistra
- preview principale `navigate`
### Colonna destra superiore
- snapshot OCR payload
- crop etichetta
### Colonna destra inferiore
- stato / metriche
- comando corrente
- lista comandi
### Metriche minime
- `frame_id`
- `processed_frames` se presente
- `loop_fps`
- `yolo_fps`
- `last_yolo_ms`
- `det_count`
- `track_count`
- `snapshot_counter`
- `yolo_mode` se disponibile
- `motion_text`
## Layout WMS Server
### Colonna sinistra
- ultima immagine ricevuta dal server
### Colonna destra
- payload request corrente
- esito OCR
- esito ACK/NACK
- contatore richieste
## Requisiti UI
- finestre ridimensionabili
- nessun blocco sul core/server
- refresh periodico leggero
- fallback a OpenCV se DearPyGUI non e' disponibile
## Strategia di implementazione
### Fase 1
- introdurre backend DearPyGUI opzionale per observer
- introdurre backend DearPyGUI opzionale per WMS server
- mantenere OpenCV come fallback
### Fase 2
- rifinire il layout
- eventuali colori/stati piu' chiari
- eventuali controlli aggiuntivi
## Criterio di successo
1. la demo risulta piu' ordinata;
2. il core intelligente non cambia comportamento;
3. observer e server possono essere lanciati senza finestre OpenCV;
4. il fallback OpenCV resta disponibile in caso di problema.

453
flywms_navigation.ini Normal file
View File

@@ -0,0 +1,453 @@
[navigation]
; OBBLIGATORIO: no.
; Ruolo: sorgente video usata per simulare la camera del drone.
; Se vuoto o "none", usa webcam 0.
; Default se non indicato: testhd.mp4
video = testhd2_edit.mp4
; OBBLIGATORIO: si.
; Ruolo: modello Ultralytics/YOLO moderno usato per rilevare gaylord ed etichette.
; Default se non indicato: C:\devel\flywms\runs\flywms_yolo11n_quick20\weights\best.pt
weights = C:\devel\flywms\runs\flywms_yolo11n_quick20\weights\best.pt
; OBBLIGATORIO: no.
; Ruolo: device usato da Ultralytics. Usa "cpu" per forzare CPU; con GPU compatibile usare "0".
; Default se non indicato: cpu
ultralytics_device = 0
; OBBLIGATORIO: no.
; Ruolo: dimensione input YOLO. 640 e' il valore usato nel training rapido.
; Default se non indicato: 640
input_size = 640
; OBBLIGATORIO: no.
; Ruolo: confidenza minima delle detection accettate dal detector.
; Default se non indicato: 0.25
min_confidence = 0.25
; OBBLIGATORIO: no.
; Ruolo: classe tracciata dalla navigazione. Le altre detection non entrano nel tracker.
; Default se non indicato: gaylord
target_class = gaylord
; OBBLIGATORIO: no.
; Ruolo: classe etichetta associata al gaylord centrato.
; Il bbox etichetta viene accettato solo se e' contenuto nel bbox del gaylord.
; Default se non indicato: etichetta
label_class = etichetta
; OBBLIGATORIO: no.
; Ruolo: numero massimo di frame in cui una track puo' non essere vista prima di essere rimossa.
; Default se non indicato: 8
max_track_missed = 8
; OBBLIGATORIO: no.
; Ruolo: soglia minima dello score che associa una detection a una track esistente.
; Default se non indicato: 0.25
min_match_score = 0.25
; OBBLIGATORIO: no.
; Ruolo: distanza massima ammessa tra centri bbox, espressa come frazione della larghezza frame.
; Default se non indicato: 0.18
max_center_distance_ratio = 0.18
; OBBLIGATORIO: no.
; Ruolo: mezza ampiezza della fascia azzurra di avvicinamento al centro.
; Non fa scattare la foto: indica solo che la track e' candidata.
; Default se non indicato: 0.18
center_tolerance_ratio = 0.18
; OBBLIGATORIO: no.
; Ruolo: tolleranza stretta dalla linea verticale centrale per scattare la foto.
; La foto parte quando il centro bbox e' entro questa soglia.
; Default se non indicato: 0.035
snapshot_line_tolerance_ratio = 0.035
; OBBLIGATORIO: no.
; Ruolo: limite verticale superiore della fascia utile della scaffalatura.
; Default se non indicato: 0.15
usable_y_min_ratio = 0.15
; OBBLIGATORIO: no.
; Ruolo: limite verticale inferiore della fascia utile della scaffalatura.
; Default se non indicato: 0.85
usable_y_max_ratio = 0.85
; OBBLIGATORIO: no.
; Ruolo: numero minimo di detection confermate prima di considerare affidabile una track.
; Default se non indicato: 3
min_track_hits = 3
; OBBLIGATORIO: no.
; Ruolo: area minima del bbox gaylord rispetto all'intero frame.
; Serve a ignorare oggetti troppo lontani/piccoli.
; Default se non indicato: 0.02
min_gaylord_area_ratio = 0.02
; OBBLIGATORIO: no.
; Ruolo: margine da bordo immagine per considerare un bbox tagliato.
; 0 disabilita questo filtro, utile con il video manuale di test.
; Default se non indicato: 0.0
edge_margin_ratio = 0.0
; OBBLIGATORIO: no.
; Ruolo: padding aggiunto al bbox centrale prima di salvare il crop inviato all'OCR remoto.
; Default se non indicato: 0.03
ocr_payload_pad_ratio = 0.03
; OBBLIGATORIO: no.
; Ruolo: padding aggiunto al bbox etichetta prima di salvare il crop inviato all'OCR remoto.
; Default se non indicato: 0.20
label_payload_pad_ratio = 0.20
; OBBLIGATORIO: no.
; Ruolo: trend minimo dell'area bbox negli ultimi frame. Valori negativi tollerano leggera uscita.
; Default se non indicato: -0.35
min_area_trend = -0.35
; OBBLIGATORIO: no.
; Ruolo: numero di candidati da valutare prima dello snapshot.
; 1 significa: scatta subito quando il centro tocca la linea.
; Default se non indicato: 1
snapshot_window_frames = 1
; OBBLIGATORIO: no.
; Ruolo: directory dove salvare frame debug, crop OCR e snapshots.jsonl.
; Default se non indicato: navigate_snapshots
snapshot_output_dir = navigate_snapshots
; OBBLIGATORIO: no.
; Ruolo: tempo massimo con cui il drone attende OCR remoto + verifica WMS.
; Con PaddleOCR il primo avvio del worker puo' richiedere alcuni secondi.
; Default se non indicato: 2.0
remote_ack_timeout_sec = 3.0
; OBBLIGATORIO: no.
; Ruolo: risposta remota simulata. Valori: always-ack, always-nack, alternate.
; Default se non indicato: always-ack
remote_ack_mode = always-ack
; OBBLIGATORIO: no.
; Ruolo: se true, invia realmente il payload etichetta al server WMS demo via HTTP.
; Se false, mantiene la risposta remota simulata locale.
; Default se non indicato: false
wms_enabled = false
; OBBLIGATORIO: no.
; Ruolo: endpoint HTTP del server WMS demo, anche su altro PC.
; Esempio rete: http://192.168.1.50:8088/api/v1/navigation-snapshot
; Default se non indicato: http://127.0.0.1:8088/api/v1/navigation-snapshot
wms_server_url = http://127.0.0.1:8088/api/v1/navigation-snapshot
; OBBLIGATORIO: no.
; Ruolo: identificativo del client/drone demo mandato nei metadati.
; Default se non indicato: flywms-demo-01
wms_client_id = flywms-demo-01
; OBBLIGATORIO: no.
; Ruolo: timeout HTTP del worker WMS, in secondi.
; Deve essere maggiore del tempo OCR server; con PaddleOCR fast caldo circa 3s, primo avvio circa 8-10s.
; Default se non indicato: 2.0
wms_timeout_sec = 12.0
; OBBLIGATORIO: no.
; Ruolo: numero massimo di snapshot in coda verso il WMS.
; Default se non indicato: 8
wms_queue_max_size = 8
; OBBLIGATORIO: no.
; Ruolo: se true, oltre al crop etichetta invia anche il frame/debug gaylord.
; Default se non indicato: true
wms_send_gaylord_debug = true
; OBBLIGATORIO: no.
; Ruolo: host su cui il server WMS demo FastAPI resta in ascolto.
; 0.0.0.0 consente connessioni da altri PC della rete.
; Default se non indicato: 0.0.0.0
wms_server_host = 0.0.0.0
; OBBLIGATORIO: no.
; Ruolo: porta del server WMS demo FastAPI.
; Default se non indicato: 8088
wms_server_port = 8088
; OBBLIGATORIO: no.
; Ruolo: directory in cui il server salva immagini e metadati ricevuti.
; Default se non indicato: wms_received
wms_received_dir = wms_received
; OBBLIGATORIO: no.
; Ruolo: risposta simulata server. Valori: always-ack, always-nack, alternate, random.
; Default se non indicato: always-ack
wms_fake_ack_mode = always-ack
; OBBLIGATORIO: no.
; Ruolo: ritardo finto del server per simulare OCR/WMS.
; Default se non indicato: 0.5
wms_fake_processing_sec = 0.5
; OBBLIGATORIO: no.
; Ruolo: se true, il server WMS demo apre le finestre OpenCV immagine/payload.
; Default se non indicato: true
wms_ui_enabled = true
; OBBLIGATORIO: no.
; Ruolo: se true, il server prova OCR reale sul crop etichetta.
; Default se non indicato: true
wms_ocr_enabled = true
; OBBLIGATORIO: no.
; Ruolo: motore OCR server. Valori: paddleocr, easyocr, tesseract, fake.
; Default se non indicato: paddleocr
wms_ocr_mode = paddleocr
; OBBLIGATORIO: no.
; Ruolo: Python del virtualenv isolato usato dal worker PaddleOCR.
; Default se non indicato: C:\devel\yolo-ocr\.venv-paddle-cpu\Scripts\python.exe
wms_paddle_python = C:\devel\yolo-ocr\.venv-paddle-cpu\Scripts\python.exe
; OBBLIGATORIO: no.
; Ruolo: script worker PaddleOCR persistente.
; Default se non indicato: flywms_paddleocr_worker.py nella cartella flywms.
wms_paddle_worker_script = C:\devel\flywms\flywms_paddleocr_worker.py
; OBBLIGATORIO: no.
; Ruolo: altezze a cui ridimensionare il crop etichetta prima dell'OCR.
; Per demo 96 e' la configurazione leggera. Per debug: 64,96,128.
; Default se non indicato: 96
wms_paddle_target_heights = 96
; OBBLIGATORIO: no.
; Ruolo: quante varianti preprocessing usare nel worker PaddleOCR.
; Valori: fast, balanced, full.
; fast usa originale, CLAHE, CLAHE+sharpen ed e' il default per il server demo.
; Default se non indicato: fast
wms_paddle_variant_set = fast
; OBBLIGATORIO: no.
; Ruolo: numero di cifre atteso nel codice UDC.
; Default se non indicato: 6
wms_paddle_expected_digits = 6
; OBBLIGATORIO: no.
; Ruolo: numero minimo di varianti OCR che devono concordare sul codice.
; Default se non indicato: 2
wms_paddle_min_votes = 2
; OBBLIGATORIO: no.
; Ruolo: confidenza minima del candidato PaddleOCR scelto.
; Default se non indicato: 0.70
wms_paddle_min_confidence = 0.70
; OBBLIGATORIO: no.
; Ruolo: prefisso del codice fallback se OCR reale non legge nulla.
; Default se non indicato: UDC
wms_fake_ocr_prefix = UDC
; OBBLIGATORIO: no.
; Ruolo: testo restituito quando OCR non determina il codice reale.
; Default se non indicato: udc non determinato
wms_undetermined_code_text = udc non determinato
; OBBLIGATORIO: no.
; Ruolo: ritardo finto OCR/fallback in secondi.
; Default se non indicato: 0.2
wms_fake_ocr_delay_sec = 0.2
; OBBLIGATORIO: no.
; Ruolo: operatore/drone simulato registrato nel payload WMS.
; Default se non indicato: udrone
wms_operator = udrone
; OBBLIGATORIO: no.
; Ruolo: sito/magazzino simulato registrato nel payload WMS.
; Default se non indicato: demo-magazzino
wms_site = demo-magazzino
; OBBLIGATORIO: no.
; Ruolo: durata simulata del movimento drone dal centro gaylord al centro etichetta.
; Default se non indicato: 3.0
label_move_sec = 3.0
; OBBLIGATORIO: no.
; Ruolo: attesa simulata per stabilizzare la foto ravvicinata dell'etichetta.
; Default se non indicato: 2.0
label_stabilization_sec = 2.0
; OBBLIGATORIO: no.
; Ruolo: durata simulata del ritorno dal centro etichetta al centro gaylord.
; Default se non indicato: 3.0
label_return_sec = 3.0
; OBBLIGATORIO: no.
; Ruolo: direzione simulata di ripartenza dopo ACK. Valori: destra, sinistra.
; Default se non indicato: destra
scan_direction = destra
; OBBLIGATORIO: no.
; Ruolo: larghezza massima delle finestre video di debug.
; Default se non indicato: 1280
preview_width = 1280
; OBBLIGATORIO: no.
; Ruolo: profilo benchmark senza finestre OpenCV.
; Se true forza no_display e usa benchmark_preview_fps come target di cattura/preview.
; Default se non indicato: false
benchmark_mode = false
; OBBLIGATORIO: no.
; Ruolo: FPS target di cattura/preview per il profilo benchmark.
; Default se non indicato: 30.0
benchmark_preview_fps = 30.0
; OBBLIGATORIO: no.
; Ruolo: se true, il video di test viene riprodotto rispettando il framerate originale.
; Default se non indicato: true
realtime_playback = true
; OBBLIGATORIO: no.
; Ruolo: FPS massimo per lettura/preview realtime. 0 usa il framerate della sorgente.
; Con video registrati puo' essere usato per simulare una preview piu' lenta, es. 24 fps.
; Con webcam/camere viene anche richiesto al driver, ma non tutti i driver rispettano il valore.
; Default se non indicato: 24.0
preview_fps = 24.0
; OBBLIGATORIO: no.
; Ruolo: se true, usa FP16/half precision per YOLO quando gira su GPU.
; Su CPU viene ignorato automaticamente.
; Default se non indicato: true
yolo_half = true
; OBBLIGATORIO: no.
; Ruolo: FPS massimo per inferenza YOLO. 0 esegue YOLO su ogni frame di preview.
; Nei frame intermedi la preview continua usando l'ultimo stato di tracking disponibile.
; Default se non indicato: 15.0
yolo_fps = 15.0
; OBBLIGATORIO: no.
; Ruolo: se true, la frequenza YOLO viene adattata allo stato delle track.
; Default se non indicato: true
adaptive_yolo_enabled = false
; OBBLIGATORIO: no.
; Ruolo: FPS YOLO quando non ci sono track attive.
; Default se non indicato: 8.0
idle_yolo_fps = 8.0
; OBBLIGATORIO: no.
; Ruolo: FPS YOLO quando ci sono track attive ma nessuna e' ancora critica.
; Default se non indicato: 12.0
tracking_yolo_fps = 12.0
; OBBLIGATORIO: no.
; Ruolo: FPS YOLO quando una track e' in stato candidate/centered.
; Default se non indicato: 15.0
critical_yolo_fps = 15.0
; OBBLIGATORIO: no.
; Ruolo: massimo numero di frame da processare. 0 significa tutto il video.
; Default se non indicato: 0
max_frames = 0
; OBBLIGATORIO: no.
; Ruolo: ogni quanti secondi stampare statistiche nel terminale.
; Default se non indicato: 2.0
stats_interval = 2.0
; OBBLIGATORIO: no.
; Ruolo: file di log delle tempistiche dettagliate per frame.
; Default se non indicato: tempistiche.txt
perf_log_path = tempistiche.txt
; OBBLIGATORIO: no.
; Ruolo: intervallo massimo tra due flush del file di tempistiche.
; Default se non indicato: 2.0
perf_log_flush_interval_sec = 2.0
; OBBLIGATORIO: no.
; Ruolo: numero di righe bufferizzate prima del flush del file di tempistiche.
; Default se non indicato: 120
perf_log_flush_lines = 120
; OBBLIGATORIO: no.
; Ruolo: ogni quanti frame aggiornare il moto apparente stimato dalle track.
; Default se non indicato: 5
motion_report_interval = 5
; OBBLIGATORIO: no.
; Ruolo: movimento medio minimo in pixel per dichiarare destra/sinistra/su/giu.
; Default se non indicato: 1.5
motion_min_pixels = 1.5
; OBBLIGATORIO: no.
; Ruolo: se true, logga nel terminale lo stato delle track e i motivi di non scatto.
; Default se non indicato: true
debug_tracks = true
; OBBLIGATORIO: no.
; Ruolo: intensita' del flash visuale simulato al momento dello scatto, da 0 a 1.
; Default se non indicato: 0.70
flash_alpha = 0.70
; OBBLIGATORIO: no.
; Ruolo: se true, disabilita tutte le finestre video. Usarlo solo per test headless.
; Default se non indicato: false
no_display = false
; OBBLIGATORIO: no.
; Ruolo: se true, avvia la pubblicazione dei dati verso un observer esterno su socket locale.
; Quando attivo, il core disabilita la UI OpenCV integrata per non rallentare la demo.
; Default se non indicato: false
observer_enabled = false
; OBBLIGATORIO: no.
; Ruolo: host TCP del publisher observer.
; Default se non indicato: 127.0.0.1
observer_host = 127.0.0.1
; OBBLIGATORIO: no.
; Ruolo: porta TCP del publisher observer.
; Default se non indicato: 8765
observer_port = 8765
; OBBLIGATORIO: no.
; Ruolo: FPS massimi della preview inviata all'observer. Tenere basso per non pesare sul core.
; Default se non indicato: 4.0
observer_preview_fps = 4.0
; OBBLIGATORIO: no.
; Ruolo: larghezza massima della preview inviata all'observer.
; Default se non indicato: 960
observer_preview_width = 960
; OBBLIGATORIO: no.
; Ruolo: qualita' JPEG delle preview inviate all'observer.
; Default se non indicato: 75
observer_jpeg_quality = 75
; OBBLIGATORIO: no.
; Ruolo: frequenza della telemetria inviata all'observer.
; Default se non indicato: 8.0
observer_telemetry_fps = 8.0
; OBBLIGATORIO: no.
; Ruolo: se true, posiziona e ridimensiona le finestre OpenCV usando i valori sotto.
; Formato finestre: x,y,width,height
; Default se non indicato: true
window_layout_enabled = true
; OBBLIGATORIO: no.
; Ruolo: posizione finestra principale con tracking gaylord/etichetta.
navigate_window = 20,40,1100,620
; OBBLIGATORIO: no.
; Ruolo: posizione finestra comandi e indicatori movimento.
commands_window = 1140,40,760,520
; OBBLIGATORIO: no.
; Ruolo: posizione finestra snapshot gaylord.
snapshot_window = 1140,590,520,360
; OBBLIGATORIO: no.
; Ruolo: posizione finestra crop etichetta.
label_window = 1140,980,520,260

2292
flywms_navigation.py Normal file

File diff suppressed because it is too large Load Diff

425
flywms_navigation_gui.py Normal file
View File

@@ -0,0 +1,425 @@
import time
from dataclasses import dataclass
from pathlib import Path
import cv2
import numpy as np
import flywms_navigation as nav
try:
import dearpygui.dearpygui as dpg
except ModuleNotFoundError as exc:
dpg = None
DEARPYGUI_IMPORT_ERROR = exc
else:
DEARPYGUI_IMPORT_ERROR = None
TEXTURE_MAIN = "texture_main"
TEXTURE_SNAPSHOT = "texture_snapshot"
TEXTURE_REGISTRY = "texture_registry"
IMAGE_MAIN = "image_main"
IMAGE_SNAPSHOT = "image_snapshot"
TEXT_STATUS = "text_status"
TEXT_COMMANDS = "text_commands"
TEXT_TRACKS = "text_tracks"
TEXT_METRICS = "text_metrics"
BUTTON_RUN = "button_run"
@dataclass(frozen=True)
class DemoFrameState:
frame_id: int
elapsed: float
source_frame: np.ndarray
display_frame: np.ndarray
snapshot_frame: np.ndarray | None
tracks: list[nav.Track]
detections_count: int
snapshots: list[nav.NavigationSnapshot]
yolo_ms: float
fps_text: str
class NavigationDemoEngine:
"""One-step navigation simulator used by the DearPyGUI shell.
The original flywms_navigation.py command-line program stays untouched.
This class reuses its detector/tracker/navigation objects and exposes a
frame-by-frame API suitable for a GUI event loop.
"""
def __init__(self, args):
self.args = args
nav.require_file(args.weights, "modello Ultralytics")
self.detector = nav.UltralyticsDetector(args.weights, args.ultralytics_device, args.yolo_half)
self.cap, self.source_name = nav.open_capture(args.video)
if not self.cap.isOpened():
raise RuntimeError(f"impossibile aprire sorgente video: {self.source_name}")
video_fps = self.cap.get(cv2.CAP_PROP_FPS)
preview_fps = args.preview_fps if args.preview_fps and args.preview_fps > 0 else video_fps
self.video_fps = video_fps
self.preview_fps = preview_fps
if args.preview_fps and args.preview_fps > 0 and (args.video is None or str(args.video).isdigit()):
self.cap.set(cv2.CAP_PROP_FPS, float(args.preview_fps))
self.frame_delay = (
1.0 / preview_fps
if args.realtime_playback and preview_fps and preview_fps > 1
else 0.0
)
self.yolo_interval = 1.0 / args.yolo_fps if args.yolo_fps and args.yolo_fps > 0 else 0.0
self.tracker = nav.LightweightTracker(
max_missed=args.max_track_missed,
min_match_score=args.min_match_score,
max_center_distance_ratio=args.max_center_distance_ratio,
)
self.navigator = nav.NavigationController(args)
self.frame_id = 0
self.start_time = time.perf_counter()
self.last_loop_end = self.start_time
self.yolo_total_ms = 0.0
self.yolo_cycles = 0
self.next_yolo_time = self.start_time
self.last_yolo_ms = 0.0
self.gaylords: list[nav.Detection] = []
self.labels: list[nav.Detection] = []
self.tracks: list[nav.Track] = []
self.stop_reason = ""
def close(self) -> None:
self.cap.release()
def step(self) -> DemoFrameState | None:
if self.frame_delay > 0:
now = time.perf_counter()
sleep_for = self.frame_delay - (now - self.last_loop_end)
if sleep_for > 0:
time.sleep(sleep_for)
self.last_loop_end = time.perf_counter()
ok, frame = self.cap.read()
if not ok:
self.stop_reason = "Fine stream"
return None
self.frame_id += 1
timestamp = time.perf_counter()
if self.args.max_frames > 0 and self.frame_id > self.args.max_frames:
self.stop_reason = f"Raggiunto max_frames={self.args.max_frames}"
return None
new_snapshots: list[nav.NavigationSnapshot] = []
run_yolo = self.yolo_interval <= 0 or timestamp >= self.next_yolo_time
if run_yolo:
self.next_yolo_time = timestamp + self.yolo_interval
detections, self.last_yolo_ms = self.detector.detect(
frame,
self.args.min_confidence,
self.args.input_size,
)
self.yolo_total_ms += self.last_yolo_ms
self.yolo_cycles += 1
self.gaylords = [
det for det in detections
if det.class_name.strip().lower() == self.args.target_class.strip().lower()
]
self.labels = [
det for det in detections
if det.class_name.strip().lower() == self.args.label_class.strip().lower()
]
self.tracks = self.tracker.update(self.gaylords, self.frame_id, frame.shape[1])
if (
self.args.motion_report_interval > 0
and self.yolo_cycles % self.args.motion_report_interval == 0
):
self.navigator.set_motion_text(
nav.estimate_motion_from_tracks(self.tracks, self.args.motion_min_pixels)
)
for track in self.tracks:
if track.missed == 0:
snapshot = self.navigator.process_track(
track,
frame,
self.frame_id,
timestamp,
self.labels,
)
if snapshot is not None:
new_snapshots.append(snapshot)
for snapshot in new_snapshots:
self.navigator.simulate_remote_response(snapshot)
elapsed = max(time.perf_counter() - self.start_time, 0.001)
fps_text = (
f"frame={self.frame_id} src_fps={nav.format_fps_value(self.video_fps)} "
f"preview_target={nav.format_fps_value(self.preview_fps)} fps={self.frame_id / elapsed:.1f} "
f"yolo_target={nav.format_fps_value(self.args.yolo_fps)} yolo_fps={self.yolo_cycles / elapsed:.1f} "
f"yolo={self.last_yolo_ms:.0f}ms det={len(self.gaylords)} labels={len(self.labels)} "
f"tracks={len(self.tracks)} "
f"snap={self.navigator.snapshot_counter}"
)
display = nav.draw_navigation_debug(
frame,
self.tracks,
self.args,
self.labels,
self.navigator.label_movement_arrow,
)
return DemoFrameState(
frame_id=self.frame_id,
elapsed=elapsed,
source_frame=frame,
display_frame=display,
snapshot_frame=self.navigator.last_ocr_payload_frame,
tracks=self.tracks,
detections_count=len(self.gaylords),
snapshots=new_snapshots,
yolo_ms=self.last_yolo_ms,
fps_text=fps_text,
)
class NavigationDemoGui:
def __init__(self, args):
self.args = args
self.engine: NavigationDemoEngine | None = None
self.running = False
self.main_texture_size: tuple[int, int] | None = None
self.snapshot_texture_size: tuple[int, int] | None = None
self.main_texture_tag = TEXTURE_MAIN
self.snapshot_texture_tag = TEXTURE_SNAPSHOT
self.last_state: DemoFrameState | None = None
def start(self) -> None:
if self.engine is None:
self.engine = NavigationDemoEngine(self.args)
dpg.set_value(TEXT_STATUS, f"Sorgente: {self.engine.source_name}")
self.running = True
dpg.configure_item(BUTTON_RUN, label="Pausa")
def pause(self) -> None:
self.running = False
dpg.configure_item(BUTTON_RUN, label="Avvia")
def toggle_run(self) -> None:
if self.running:
self.pause()
else:
self.start()
def reset(self) -> None:
self.pause()
if self.engine is not None:
self.engine.close()
self.engine = NavigationDemoEngine(self.args)
self.last_state = None
self.main_texture_size = None
self.snapshot_texture_size = None
dpg.set_value(TEXT_COMMANDS, "Nessun comando generato")
dpg.set_value(TEXT_TRACKS, "Nessuna track")
dpg.set_value(TEXT_METRICS, "Metriche non disponibili")
dpg.set_value(TEXT_STATUS, f"Reset completato. Sorgente: {self.engine.source_name}")
def step_once(self) -> None:
if self.engine is None:
self.engine = NavigationDemoEngine(self.args)
state = self.engine.step()
if state is None:
self.pause()
dpg.set_value(TEXT_STATUS, self.engine.stop_reason if self.engine else "Stop")
return
self.last_state = state
self._update_ui(state)
def tick(self) -> None:
if self.running:
self.step_once()
def close(self) -> None:
if self.engine is not None:
self.engine.close()
def _update_ui(self, state: DemoFrameState) -> None:
self._set_image_texture(
frame=state.display_frame,
image_tag=IMAGE_MAIN,
size_attr="main_texture_size",
tag_attr="main_texture_tag",
max_display_width=960,
)
if state.snapshot_frame is not None:
self._set_image_texture(
frame=nav.resize_preview(state.snapshot_frame, 520),
image_tag=IMAGE_SNAPSHOT,
size_attr="snapshot_texture_size",
tag_attr="snapshot_texture_tag",
max_display_width=520,
)
assert self.engine is not None
avg_yolo = self.engine.yolo_total_ms / max(self.engine.yolo_cycles, 1)
dpg.set_value(
TEXT_METRICS,
"\n".join([
state.fps_text,
f"YOLO ultimo: {state.yolo_ms:.1f} ms",
f"YOLO medio: {avg_yolo:.1f} ms",
self.engine.navigator.motion_text,
f"Snapshot salvati: {self.engine.navigator.snapshot_counter}",
f"Output: {Path(self.args.snapshot_output_dir).resolve()}",
]),
)
dpg.set_value(
TEXT_COMMANDS,
"\n".join(self.engine.navigator.last_command_lines)
if self.engine.navigator.last_command_lines
else "Nessun comando generato",
)
dpg.set_value(TEXT_TRACKS, self._format_tracks(state.tracks))
if state.snapshots:
dpg.set_value(
TEXT_STATUS,
f"Snapshot {state.snapshots[-1].snapshot_id:04d} acquisito "
f"su {state.snapshots[-1].simulated_position}",
)
def _set_image_texture(
self,
frame: np.ndarray,
image_tag: str,
size_attr: str,
tag_attr: str,
max_display_width: int,
) -> None:
h, w = frame.shape[:2]
rgba = bgr_to_rgba_float(frame)
display_scale = min(1.0, max_display_width / float(w))
display_w = max(1, int(w * display_scale))
display_h = max(1, int(h * display_scale))
old_size = getattr(self, size_attr)
if old_size != (w, h):
old_texture_tag = getattr(self, tag_attr)
new_texture_tag = dpg.generate_uuid()
dpg.add_dynamic_texture(
w,
h,
rgba,
tag=new_texture_tag,
parent=TEXTURE_REGISTRY,
)
dpg.configure_item(
image_tag,
texture_tag=new_texture_tag,
width=display_w,
height=display_h,
)
if dpg.does_item_exist(old_texture_tag):
dpg.delete_item(old_texture_tag)
setattr(self, tag_attr, new_texture_tag)
setattr(self, size_attr, (w, h))
return
dpg.configure_item(image_tag, width=display_w, height=display_h)
dpg.set_value(getattr(self, tag_attr), rgba)
@staticmethod
def _format_tracks(tracks: list[nav.Track]) -> str:
if not tracks:
return "Nessuna track"
lines = []
for track in tracks[:12]:
cx, cy = nav.bbox_center(track.bbox)
lines.append(
f"#{track.id:03d} {track.state:<11} "
f"conf={track.confidence:.2f} hits={track.hits:<3} "
f"missed={track.missed:<2} center=({cx:.0f},{cy:.0f}) "
f"trend={track.area_trend():+.2f} {track.last_candidate_reason}"
)
return "\n".join(lines)
def bgr_to_rgba_float(frame: np.ndarray) -> np.ndarray:
rgba = nav.cuda_cvt_color(frame, cv2.COLOR_BGR2RGBA)
return np.asarray(rgba, dtype=np.float32).ravel() / 255.0
def build_ui(app: NavigationDemoGui) -> None:
dpg.create_context()
with dpg.font_registry():
default_font = dpg.add_font("C:/Windows/Fonts/segoeui.ttf", 17)
mono_font = dpg.add_font("C:/Windows/Fonts/consola.ttf", 15)
with dpg.texture_registry(show=False, tag=TEXTURE_REGISTRY):
blank = np.zeros((32, 32, 4), dtype=np.float32).ravel()
dpg.add_dynamic_texture(32, 32, blank, tag=TEXTURE_MAIN)
dpg.add_dynamic_texture(32, 32, blank, tag=TEXTURE_SNAPSHOT)
with dpg.window(tag="main_window", label="FlyWMS Navigation Demo"):
with dpg.group(horizontal=True):
dpg.add_button(label="Avvia", tag=BUTTON_RUN, callback=lambda: app.toggle_run())
dpg.add_button(label="Step", callback=lambda: app.step_once())
dpg.add_button(label="Reset", callback=lambda: app.reset())
dpg.add_text("Pronto", tag=TEXT_STATUS)
dpg.add_separator()
with dpg.group(horizontal=True):
with dpg.child_window(width=980, height=-1, border=False):
dpg.add_image(TEXTURE_MAIN, tag=IMAGE_MAIN)
with dpg.child_window(width=560, height=-1, border=False):
dpg.add_text("Metriche")
dpg.add_text("Metriche non disponibili", tag=TEXT_METRICS)
dpg.bind_item_font(TEXT_METRICS, mono_font)
dpg.add_separator()
dpg.add_text("Comandi")
dpg.add_text("Nessun comando generato", tag=TEXT_COMMANDS)
dpg.bind_item_font(TEXT_COMMANDS, mono_font)
dpg.add_separator()
dpg.add_text("Payload OCR")
dpg.add_image(TEXTURE_SNAPSHOT, tag=IMAGE_SNAPSHOT)
dpg.add_separator()
dpg.add_text("Track")
dpg.add_text("Nessuna track", tag=TEXT_TRACKS)
dpg.bind_item_font(TEXT_TRACKS, mono_font)
dpg.bind_font(default_font)
dpg.create_viewport(title="FlyWMS Navigation Demo", width=1600, height=980)
dpg.setup_dearpygui()
dpg.show_viewport()
dpg.set_primary_window("main_window", True)
def main() -> int:
if dpg is None:
print(
"DearPyGUI non e' installato. Installa il pacchetto con:\n"
" python -m pip install dearpygui\n"
f"Dettaglio import: {DEARPYGUI_IMPORT_ERROR}",
flush=True,
)
return 1
args = nav.parse_args()
args.no_display = True
app = NavigationDemoGui(args)
build_ui(app)
try:
while dpg.is_dearpygui_running():
app.tick()
dpg.render_dearpygui_frame()
finally:
app.close()
dpg.destroy_context()
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,515 @@
import argparse
import base64
import ctypes
import json
import socket
import time
import cv2
import numpy as np
import flywms_navigation as nav
try:
import dearpygui.dearpygui as dpg
except ModuleNotFoundError:
dpg = None
NAV_TEXTURE = "obs_nav_texture"
SNAPSHOT_TEXTURE = "obs_snapshot_texture"
LABEL_TEXTURE = "obs_label_texture"
NAV_IMAGE = "obs_nav_image"
SNAPSHOT_IMAGE = "obs_snapshot_image"
LABEL_IMAGE = "obs_label_image"
TEXT_STATUS = "obs_status"
TEXT_COMMANDS = "obs_commands"
TEXT_TIMELINE = "obs_timeline"
BADGE_CONNECTION = "obs_badge_connection"
BADGE_YOLO = "obs_badge_yolo"
BADGE_PHASE = "obs_badge_phase"
BADGE_WMS = "obs_badge_wms"
METRIC_LOOP_FPS = "obs_metric_loop_fps"
METRIC_YOLO_FPS = "obs_metric_yolo_fps"
METRIC_YOLO_MS = "obs_metric_yolo_ms"
METRIC_SNAPSHOTS = "obs_metric_snapshots"
METRIC_FRAME_ID = "obs_metric_frame_id"
METRIC_TRACKS = "obs_metric_tracks"
METRIC_LABELS = "obs_metric_labels"
METRIC_PROGRESS = "obs_metric_progress"
NAV_W = 960
NAV_H = 540
SNAPSHOT_W = 520
SNAPSHOT_H = 300
LABEL_W = 520
LABEL_H = 220
THEME_BADGE_OK = "obs_theme_badge_ok"
THEME_BADGE_WARN = "obs_theme_badge_warn"
THEME_BADGE_ALERT = "obs_theme_badge_alert"
THEME_BADGE_IDLE = "obs_theme_badge_idle"
def parse_args():
pre = argparse.ArgumentParser(add_help=False)
pre.add_argument("--config", default=nav.DEFAULT_CONFIG_PATH, help="File configurazione INI")
pre_args, _ = pre.parse_known_args()
defaults = nav.load_navigation_config(pre_args.config)
ap = argparse.ArgumentParser(parents=[pre])
ap.add_argument("--host", default=defaults["observer_host"], help="Host publisher observer")
ap.add_argument("--port", type=int, default=defaults["observer_port"], help="Porta publisher observer")
ap.add_argument("--reconnect-sec", type=float, default=1.0, help="Attesa tra tentativi di reconnessione")
ap.add_argument("--ui-backend", choices=["auto", "opencv", "dearpygui"], default="dearpygui",
help="Backend UI observer")
return ap.parse_args()
def decode_preview(message: dict[str, object]) -> np.ndarray | None:
image_b64 = message.get("image_b64")
if not isinstance(image_b64, str):
return None
try:
raw = base64.b64decode(image_b64.encode("ascii"))
array = np.frombuffer(raw, dtype=np.uint8)
return cv2.imdecode(array, cv2.IMREAD_COLOR)
except (ValueError, cv2.error):
return None
def choose_backend(requested: str) -> str:
if requested == "opencv":
return "opencv"
if requested == "dearpygui":
if dpg is None:
raise RuntimeError("DearPyGUI non disponibile")
return "dearpygui"
if dpg is not None:
return "dearpygui"
return "opencv"
def extract_progress_text(telemetry: dict[str, object]) -> str:
pose = telemetry.get("capture_pose") if isinstance(telemetry.get("capture_pose"), dict) else {}
route_progress = pose.get("route_progress_ratio")
if isinstance(route_progress, (int, float)):
return f"{float(route_progress) * 100.0:.1f}%"
return "n/d"
def format_commands(telemetry: dict[str, object]) -> str:
command_lines = telemetry.get("command_lines") or ["Observer in attesa dati"]
if not isinstance(command_lines, list):
command_lines = [str(command_lines)]
return "\n".join(str(item) for item in command_lines)
def empty_placeholder(width: int, height: int, text: str) -> np.ndarray:
canvas = np.full((height, width, 3), 235, dtype=np.uint8)
cv2.putText(
canvas,
text,
(24, height // 2),
cv2.FONT_HERSHEY_SIMPLEX,
0.8,
(30, 30, 30),
2,
cv2.LINE_AA,
)
return canvas
def frame_to_texture_data(frame: np.ndarray, target_w: int, target_h: int) -> list[float]:
src_h, src_w = frame.shape[:2]
if src_h <= 0 or src_w <= 0:
canvas = np.zeros((target_h, target_w, 3), dtype=np.uint8)
else:
scale = min(target_w / float(src_w), target_h / float(src_h))
draw_w = max(1, int(round(src_w * scale)))
draw_h = max(1, int(round(src_h * scale)))
resized = cv2.resize(frame, (draw_w, draw_h), interpolation=cv2.INTER_AREA if scale < 1.0 else cv2.INTER_LINEAR)
canvas = np.full((target_h, target_w, 3), 18, dtype=np.uint8)
x0 = (target_w - draw_w) // 2
y0 = (target_h - draw_h) // 2
canvas[y0:y0 + draw_h, x0:x0 + draw_w] = resized
rgba = cv2.cvtColor(canvas, cv2.COLOR_BGR2RGBA)
return (rgba.astype(np.float32).ravel() / 255.0).tolist()
def restore_window_by_title(title: str) -> None:
if not hasattr(ctypes, "windll"):
return
user32 = ctypes.windll.user32
hwnd = user32.FindWindowW(None, title)
if hwnd:
user32.ShowWindow(hwnd, 9) # SW_RESTORE
user32.SetForegroundWindow(hwnd)
def classify_phase(telemetry: dict[str, object]) -> str:
command_lines = telemetry.get("command_lines")
if not isinstance(command_lines, list):
command_lines = []
joined = " | ".join(str(line) for line in command_lines)
if "ATTENDI_WMS" in joined or "ATTENDI_ACK" in joined:
return "WAIT WMS"
if "SCATTA_FOTO_ETICHETTA" in joined or "INVIA_ROI_REMOTA" in joined:
return "CAPTURE"
if "CENTRA_ETICHETTA" in joined or "RITORNA_CENTRO_GAYLORD" in joined:
return "CENTERING"
if "STOP" in joined:
return "PAUSED"
return "SCANNING"
def classify_wms_status(telemetry: dict[str, object]) -> str:
command_lines = telemetry.get("command_lines")
if not isinstance(command_lines, list):
command_lines = []
joined = " | ".join(str(line) for line in command_lines)
if "WMS_ACK" in joined or "ACK_RICEVUTO" in joined:
return "ACK"
if "WMS_NACK" in joined or "NACK_RICEVUTO" in joined:
return "NACK"
if "WMS_TIMEOUT" in joined or "TIMEOUT" in joined:
return "TIMEOUT"
if "ATTENDI_WMS" in joined or "ATTENDI_ACK" in joined:
return "WAIT"
return "IDLE"
class DearPyGuiObserver:
def __init__(self, args):
if dpg is None:
raise RuntimeError("DearPyGUI non disponibile")
self.args = args
self.latest_navigate: np.ndarray | None = None
self.latest_snapshot: np.ndarray | None = None
self.latest_label: np.ndarray | None = None
self.telemetry: dict[str, object] = {
"command_lines": ["Observer in attesa dati"],
"motion_text": "MOTO: n/d",
}
self.sock: socket.socket | None = None
self.recv_buffer = ""
self.last_connect_attempt = 0.0
self.connected = False
self.event_history: list[str] = []
self.last_command_signature = ""
self.last_phase = ""
self.last_wms_status = ""
def run(self) -> int:
self._build_ui()
try:
while dpg.is_dearpygui_running():
self._poll_socket()
self._refresh_ui()
dpg.render_dearpygui_frame()
finally:
self._close_socket()
dpg.destroy_context()
return 0
def _build_ui(self) -> None:
dpg.create_context()
self._build_themes()
with dpg.texture_registry(show=False):
self._create_texture(NAV_TEXTURE, frame_to_texture_data(empty_placeholder(NAV_W, NAV_H, "In attesa preview"), NAV_W, NAV_H), NAV_W, NAV_H)
self._create_texture(SNAPSHOT_TEXTURE, frame_to_texture_data(empty_placeholder(SNAPSHOT_W, SNAPSHOT_H, "In attesa snapshot"), SNAPSHOT_W, SNAPSHOT_H), SNAPSHOT_W, SNAPSHOT_H)
self._create_texture(LABEL_TEXTURE, frame_to_texture_data(empty_placeholder(LABEL_W, LABEL_H, "In attesa etichetta"), LABEL_W, LABEL_H), LABEL_W, LABEL_H)
with dpg.window(label="FlyWMS Observer", tag="main_window", width=1600, height=980):
with dpg.group(horizontal=True):
with dpg.child_window(width=980, height=920, border=True):
dpg.add_text("Preview navigazione")
dpg.add_image(NAV_TEXTURE, tag=NAV_IMAGE)
with dpg.child_window(width=580, height=920, border=True):
with dpg.group(horizontal=True):
dpg.add_button(label="CONN", tag=BADGE_CONNECTION, width=86, height=28, enabled=False)
dpg.add_button(label="YOLO", tag=BADGE_YOLO, width=86, height=28, enabled=False)
dpg.add_button(label="PHASE", tag=BADGE_PHASE, width=130, height=28, enabled=False)
dpg.add_button(label="WMS", tag=BADGE_WMS, width=86, height=28, enabled=False)
dpg.add_spacer(height=10)
dpg.add_text("Snapshot OCR payload")
dpg.add_image(SNAPSHOT_TEXTURE, tag=SNAPSHOT_IMAGE)
dpg.add_spacer(height=12)
dpg.add_text("Crop etichetta")
dpg.add_image(LABEL_TEXTURE, tag=LABEL_IMAGE)
dpg.add_spacer(height=12)
dpg.add_separator()
dpg.add_text("Stato observer")
dpg.add_text("Observer in attesa connessione", tag=TEXT_STATUS, wrap=540)
dpg.add_separator()
dpg.add_text("Metriche")
with dpg.table(header_row=False, borders_innerH=True, borders_outerH=True, borders_innerV=True, borders_outerV=True, resizable=False, policy=dpg.mvTable_SizingStretchProp):
dpg.add_table_column()
dpg.add_table_column()
for label, tag in (
("Loop FPS", METRIC_LOOP_FPS),
("YOLO FPS", METRIC_YOLO_FPS),
("YOLO ms", METRIC_YOLO_MS),
("Snapshot", METRIC_SNAPSHOTS),
("Frame", METRIC_FRAME_ID),
("Track attive", METRIC_TRACKS),
("Etichette", METRIC_LABELS),
("Progresso", METRIC_PROGRESS),
):
with dpg.table_row():
dpg.add_text(label)
dpg.add_text("n/d", tag=tag)
dpg.add_separator()
dpg.add_text("Comandi")
dpg.add_input_text(tag=TEXT_COMMANDS, multiline=True, readonly=True, width=540, height=260, default_value="Observer in attesa dati")
dpg.add_separator()
dpg.add_text("Timeline eventi")
dpg.add_input_text(tag=TEXT_TIMELINE, multiline=True, readonly=True, width=540, height=180, default_value="In attesa eventi")
dpg.create_viewport(title="FlyWMS Observer", width=1600, height=980)
dpg.setup_dearpygui()
dpg.show_viewport()
time.sleep(0.05)
restore_window_by_title("FlyWMS Observer")
self._set_badge(BADGE_CONNECTION, "DISCONNECTED", THEME_BADGE_ALERT)
self._set_badge(BADGE_YOLO, "YOLO OFF", THEME_BADGE_IDLE)
self._set_badge(BADGE_PHASE, "IDLE", THEME_BADGE_IDLE)
self._set_badge(BADGE_WMS, "WMS IDLE", THEME_BADGE_IDLE)
def _build_themes(self) -> None:
def make_badge_theme(tag: str, button_color: tuple[int, int, int], text_color: tuple[int, int, int] = (255, 255, 255)) -> None:
with dpg.theme(tag=tag):
with dpg.theme_component(dpg.mvButton):
dpg.add_theme_color(dpg.mvThemeCol_Button, button_color)
dpg.add_theme_color(dpg.mvThemeCol_ButtonHovered, button_color)
dpg.add_theme_color(dpg.mvThemeCol_ButtonActive, button_color)
dpg.add_theme_color(dpg.mvThemeCol_Text, text_color)
dpg.add_theme_style(dpg.mvStyleVar_FrameRounding, 6)
dpg.add_theme_style(dpg.mvStyleVar_FramePadding, 8, 6)
make_badge_theme(THEME_BADGE_OK, (40, 140, 70))
make_badge_theme(THEME_BADGE_WARN, (196, 132, 21))
make_badge_theme(THEME_BADGE_ALERT, (176, 52, 52))
make_badge_theme(THEME_BADGE_IDLE, (80, 88, 102))
def _create_texture(self, tag: str, data: list[float], width: int, height: int) -> None:
dpg.add_dynamic_texture(width=width, height=height, default_value=data, tag=tag)
def _set_texture(self, texture_tag: str, frame: np.ndarray, target_w: int, target_h: int) -> None:
dpg.set_value(texture_tag, frame_to_texture_data(frame, target_w, target_h))
def _set_badge(self, tag: str, label: str, theme_tag: str) -> None:
dpg.configure_item(tag, label=label)
dpg.bind_item_theme(tag, theme_tag)
def _append_event(self, text: str) -> None:
stamp = time.strftime("%H:%M:%S")
self.event_history.insert(0, f"{stamp} {text}")
self.event_history = self.event_history[:12]
def _poll_socket(self) -> None:
now = time.perf_counter()
if self.sock is None:
if now - self.last_connect_attempt < max(0.2, self.args.reconnect_sec):
return
self.last_connect_attempt = now
try:
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.connect((self.args.host, self.args.port))
self.sock.setblocking(False)
self.recv_buffer = ""
self.connected = True
nav.log("[OBS] connesso al core")
self._append_event("Observer connesso al core")
except OSError as exc:
self._close_socket()
self.connected = False
dpg.set_value(TEXT_STATUS, f"Observer non connesso: {exc}")
return
try:
while True:
try:
chunk = self.sock.recv(65536)
if not chunk:
raise ConnectionError("publisher disconnesso")
self.recv_buffer += chunk.decode("utf-8", errors="replace")
except BlockingIOError:
break
except OSError as exc:
if getattr(exc, "winerror", None) in (10035,):
break
raise
while True:
newline_pos = self.recv_buffer.find("\n")
if newline_pos < 0:
break
line = self.recv_buffer[:newline_pos].strip()
self.recv_buffer = self.recv_buffer[newline_pos + 1:]
if not line:
continue
try:
message = json.loads(line)
except json.JSONDecodeError:
continue
msg_type = message.get("type")
if msg_type == "telemetry":
self.telemetry = message
elif msg_type == "preview":
frame = decode_preview(message)
stream = message.get("stream")
if frame is None:
continue
if stream == "navigate":
self.latest_navigate = frame
elif stream == "snapshot":
self.latest_snapshot = frame
elif stream == "label":
self.latest_label = frame
except (ConnectionError, OSError) as exc:
nav.log(f"[OBS] connessione persa o non disponibile: {exc}")
self._close_socket()
self.connected = False
dpg.set_value(TEXT_STATUS, f"Connessione persa: {exc}")
self._append_event(f"Connessione persa: {exc}")
def _refresh_ui(self) -> None:
if self.latest_navigate is not None:
self._set_texture(NAV_TEXTURE, self.latest_navigate, NAV_W, NAV_H)
if self.latest_snapshot is not None:
self._set_texture(SNAPSHOT_TEXTURE, self.latest_snapshot, SNAPSHOT_W, SNAPSHOT_H)
if self.latest_label is not None:
self._set_texture(LABEL_TEXTURE, self.latest_label, LABEL_W, LABEL_H)
state = "connesso" if self.connected else "in attesa connessione"
dpg.set_value(TEXT_STATUS, f"Observer {state} su {self.args.host}:{self.args.port}")
dpg.set_value(TEXT_COMMANDS, format_commands(self.telemetry))
dpg.set_value(METRIC_LOOP_FPS, f"{float(self.telemetry.get('loop_fps', 0.0)):.2f}")
dpg.set_value(METRIC_YOLO_FPS, f"{float(self.telemetry.get('yolo_fps', 0.0)):.2f}")
dpg.set_value(METRIC_YOLO_MS, f"{float(self.telemetry.get('last_yolo_ms', 0.0)):.1f}")
dpg.set_value(METRIC_SNAPSHOTS, str(self.telemetry.get("snapshot_counter", 0)))
dpg.set_value(METRIC_FRAME_ID, str(self.telemetry.get("frame_id", "n/d")))
dpg.set_value(METRIC_TRACKS, f"{self.telemetry.get('active_track_count', 0)} / {self.telemetry.get('track_count', 0)}")
dpg.set_value(METRIC_LABELS, str(self.telemetry.get("label_count", 0)))
dpg.set_value(METRIC_PROGRESS, extract_progress_text(self.telemetry))
self._set_badge(BADGE_CONNECTION, "CONNECTED" if self.connected else "DISCONNECTED", THEME_BADGE_OK if self.connected else THEME_BADGE_ALERT)
run_yolo = bool(self.telemetry.get("run_yolo", False))
yolo_mode = str(self.telemetry.get("yolo_mode", "idle")).upper()
self._set_badge(BADGE_YOLO, f"YOLO {yolo_mode}" if run_yolo else "YOLO IDLE", THEME_BADGE_OK if run_yolo else THEME_BADGE_IDLE)
phase = classify_phase(self.telemetry)
phase_theme = THEME_BADGE_WARN if phase in ("CAPTURE", "CENTERING", "WAIT WMS", "PAUSED") else THEME_BADGE_OK
self._set_badge(BADGE_PHASE, phase, phase_theme)
wms_status = classify_wms_status(self.telemetry)
wms_theme = THEME_BADGE_IDLE
if wms_status == "ACK":
wms_theme = THEME_BADGE_OK
elif wms_status in ("WAIT",):
wms_theme = THEME_BADGE_WARN
elif wms_status in ("NACK", "TIMEOUT"):
wms_theme = THEME_BADGE_ALERT
self._set_badge(BADGE_WMS, f"WMS {wms_status}", wms_theme)
command_signature = str(self.telemetry.get("command_text") or "")
if command_signature and command_signature != self.last_command_signature:
self._append_event(command_signature)
self.last_command_signature = command_signature
if phase != self.last_phase:
self._append_event(f"Fase -> {phase}")
self.last_phase = phase
if wms_status != self.last_wms_status:
self._append_event(f"Stato WMS -> {wms_status}")
self.last_wms_status = wms_status
dpg.set_value(TEXT_TIMELINE, "\n".join(self.event_history) if self.event_history else "In attesa eventi")
def _close_socket(self) -> None:
try:
if self.sock is not None:
self.sock.close()
except OSError:
pass
self.sock = None
self.recv_buffer = ""
def run_opencv_observer(args) -> int:
nav.log(f"[OBS] avvio observer {args.host}:{args.port}")
latest_navigate: np.ndarray | None = None
latest_snapshot: np.ndarray | None = None
latest_label: np.ndarray | None = None
telemetry: dict[str, object] = {
"command_lines": ["Observer in attesa dati"],
"motion_text": "MOTO: n/d",
}
cv2.namedWindow("flywms observer", cv2.WINDOW_NORMAL)
cv2.namedWindow("flywms observer comandi", cv2.WINDOW_NORMAL)
cv2.namedWindow("flywms observer snapshot", cv2.WINDOW_NORMAL)
cv2.namedWindow("flywms observer etichetta", cv2.WINDOW_NORMAL)
while True:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
sock.connect((args.host, args.port))
nav.log("[OBS] connesso al core")
fileobj = sock.makefile("r", encoding="utf-8", newline="\n")
while True:
line = fileobj.readline()
if not line:
raise ConnectionError("publisher disconnesso")
message = json.loads(line)
msg_type = message.get("type")
if msg_type == "telemetry":
telemetry = message
elif msg_type == "preview":
frame = decode_preview(message)
stream = message.get("stream")
if frame is None:
continue
if stream == "navigate":
latest_navigate = frame
elif stream == "snapshot":
latest_snapshot = frame
elif stream == "label":
latest_label = frame
if latest_navigate is not None:
cv2.imshow("flywms observer", latest_navigate)
if latest_snapshot is not None:
cv2.imshow("flywms observer snapshot", latest_snapshot)
if latest_label is not None:
cv2.imshow("flywms observer etichetta", latest_label)
panel = nav.draw_commands_window(
[str(item) for item in (telemetry.get("command_lines") or ["Observer attivo"])],
str(telemetry.get("motion_text") or "MOTO: n/d"),
)
cv2.imshow("flywms observer comandi", panel)
key = cv2.waitKey(1) & 0xFF
if key in (27, ord("q")):
nav.log("[OBS] terminato da tastiera")
return 0
except (ConnectionError, OSError, json.JSONDecodeError) as exc:
nav.log(f"[OBS] connessione persa o non disponibile: {exc}")
wait_deadline = time.perf_counter() + max(0.2, args.reconnect_sec)
while time.perf_counter() < wait_deadline:
key = cv2.waitKey(50) & 0xFF
if key in (27, ord("q")):
nav.log("[OBS] terminato da tastiera")
return 0
continue
finally:
try:
sock.close()
except OSError:
pass
def main() -> int:
args = parse_args()
backend = choose_backend(args.ui_backend)
nav.log(f"[OBS] backend UI: {backend}")
if backend == "dearpygui":
return DearPyGuiObserver(args).run()
return run_opencv_observer(args)
if __name__ == "__main__":
raise SystemExit(main())

340
flywms_paddleocr_worker.py Normal file
View File

@@ -0,0 +1,340 @@
from __future__ import annotations
import json
import os
import re
import site
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import cv2
import numpy as np
CUDA_MIN_PIXELS = 640 * 360
def opencv_cuda_available() -> bool:
try:
return hasattr(cv2, "cuda") and cv2.cuda.getCudaEnabledDeviceCount() > 0
except cv2.error:
return False
OPENCV_CUDA_AVAILABLE = opencv_cuda_available()
def cuda_resize(
image: np.ndarray,
size: tuple[int, int],
interpolation: int = cv2.INTER_LINEAR,
min_pixels: int = CUDA_MIN_PIXELS,
) -> np.ndarray:
if not OPENCV_CUDA_AVAILABLE or image.size < min_pixels:
return cv2.resize(image, size, interpolation=interpolation)
try:
gpu = cv2.cuda_GpuMat()
gpu.upload(image)
return cv2.cuda.resize(gpu, size, interpolation=interpolation).download()
except cv2.error:
return cv2.resize(image, size, interpolation=interpolation)
def cuda_cvt_color(
image: np.ndarray,
code: int,
min_pixels: int = CUDA_MIN_PIXELS,
) -> np.ndarray:
if not OPENCV_CUDA_AVAILABLE or image.size < min_pixels:
return cv2.cvtColor(image, code)
try:
gpu = cv2.cuda_GpuMat()
gpu.upload(image)
return cv2.cuda.cvtColor(gpu, code).download()
except cv2.error:
return cv2.cvtColor(image, code)
@dataclass(frozen=True)
class Candidate:
text: str
score: float
variant: str
def add_nvidia_dll_dirs() -> None:
if os.name != "nt":
return
for site_dir in site.getsitepackages():
nvidia_root = Path(site_dir) / "nvidia"
if not nvidia_root.exists():
continue
for bin_dir in nvidia_root.glob("*/bin"):
if bin_dir.exists():
os.add_dll_directory(str(bin_dir))
def resize_to_height(image: np.ndarray, target_height: int) -> np.ndarray:
h, w = image.shape[:2]
if h == target_height:
return image
scale = target_height / max(1, h)
return cuda_resize(image, (max(1, int(w * scale)), target_height), interpolation=cv2.INTER_CUBIC)
def add_border(image: np.ndarray, ratio: float = 0.08) -> np.ndarray:
h, w = image.shape[:2]
pad_x = max(2, int(w * ratio))
pad_y = max(2, int(h * ratio))
return cv2.copyMakeBorder(
image,
pad_y,
pad_y,
pad_x,
pad_x,
cv2.BORDER_CONSTANT,
value=(255, 255, 255),
)
def clahe_bgr(image: np.ndarray) -> np.ndarray:
lab = cuda_cvt_color(image, cv2.COLOR_BGR2LAB)
l_channel, a_channel, b_channel = cv2.split(lab)
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(4, 4))
merged = cv2.merge([clahe.apply(l_channel), a_channel, b_channel])
return cuda_cvt_color(merged, cv2.COLOR_LAB2BGR)
def sharpen(image: np.ndarray, strength: float = 0.7) -> np.ndarray:
blurred = cv2.GaussianBlur(image, (0, 0), sigmaX=1.0)
return cv2.addWeighted(image, 1.0 + strength, blurred, -strength, 0)
def variants_for_height(image: np.ndarray, target_height: int) -> dict[str, np.ndarray]:
base = resize_to_height(image, target_height)
bordered = add_border(base)
gray = cuda_cvt_color(base, cv2.COLOR_BGR2GRAY)
clahe = clahe_bgr(base)
sharp = sharpen(clahe)
adaptive = cv2.adaptiveThreshold(
gray,
255,
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY,
17,
5,
)
otsu = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)[1]
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (2, 2))
close = cv2.morphologyEx(otsu, cv2.MORPH_CLOSE, kernel, iterations=1)
denoise = cv2.fastNlMeansDenoising(gray, h=7, templateWindowSize=7, searchWindowSize=21)
return {
"orig": base,
"orig_border": bordered,
"gray": cuda_cvt_color(gray, cv2.COLOR_GRAY2BGR),
"clahe": clahe,
"clahe_sharp": sharp,
"adaptive": cuda_cvt_color(adaptive, cv2.COLOR_GRAY2BGR),
"otsu": cuda_cvt_color(otsu, cv2.COLOR_GRAY2BGR),
"otsu_close": cuda_cvt_color(close, cv2.COLOR_GRAY2BGR),
"denoise": cuda_cvt_color(denoise, cv2.COLOR_GRAY2BGR),
}
def build_variants(image: np.ndarray, target_heights: list[int]) -> dict[str, np.ndarray]:
all_variants: dict[str, np.ndarray] = {}
for height in target_heights:
for name, variant in variants_for_height(image, height).items():
all_variants[f"h{height}_{name}"] = variant
return all_variants
def filter_variants(all_variants: dict[str, np.ndarray], variant_set: str) -> dict[str, np.ndarray]:
if variant_set == "full":
return all_variants
balanced_names = ("orig", "orig_border", "clahe", "clahe_sharp", "adaptive")
fast_names = ("orig", "clahe", "clahe_sharp")
allowed = balanced_names if variant_set == "balanced" else fast_names
return {
name: image
for name, image in all_variants.items()
if any(name.endswith(f"_{suffix}") for suffix in allowed)
}
def extract_candidates(result: Any) -> list[tuple[str, float]]:
candidates: list[tuple[str, float]] = []
def walk(value: Any) -> None:
if value is None:
return
if isinstance(value, dict):
rec_texts = value.get("rec_texts")
rec_scores = value.get("rec_scores")
if isinstance(rec_texts, list):
for idx, text in enumerate(rec_texts):
score = rec_scores[idx] if isinstance(rec_scores, list) and idx < len(rec_scores) else 0.0
if isinstance(text, str):
candidates.append((text, float(score)))
text = value.get("rec_text") or value.get("text")
score = value.get("rec_score") or value.get("score")
if isinstance(text, str):
candidates.append((text, float(score) if score is not None else 0.0))
for child in value.values():
walk(child)
return
if isinstance(value, (list, tuple)):
if len(value) >= 2 and isinstance(value[1], tuple) and len(value[1]) >= 2:
text, score = value[1][0], value[1][1]
if isinstance(text, str):
candidates.append((text, float(score)))
for child in value:
walk(child)
walk(result)
dedup: dict[str, float] = {}
for text, score in candidates:
digits = re.sub(r"\D+", "", text)
if not digits:
continue
dedup[digits] = max(score, dedup.get(digits, 0.0))
return sorted(dedup.items(), key=lambda item: item[1], reverse=True)
def choose_best(candidates: list[Candidate], expected_digits: int) -> tuple[str, float, int, str, float]:
if not candidates:
return "", 0.0, 0, "", 0.0
grouped: dict[str, list[Candidate]] = {}
for candidate in candidates:
grouped.setdefault(candidate.text, []).append(candidate)
best_text = ""
best_rank = -999.0
best_score = 0.0
best_votes = 0
best_variant = ""
for text, group in grouped.items():
max_conf = max(item.score for item in group)
votes = len(group)
unique_variants = len({item.variant for item in group})
length_penalty = abs(len(text) - expected_digits) * 0.35
exact_bonus = 0.35 if len(text) == expected_digits else 0.0
consensus_bonus = min(0.30, votes * 0.035) + min(0.20, unique_variants * 0.025)
rank = max_conf + exact_bonus + consensus_bonus - length_penalty
if rank > best_rank:
best_text = text
best_rank = rank
best_score = max_conf
best_votes = votes
best_variant = max(group, key=lambda item: item.score).variant
return best_text, best_score, best_votes, best_variant, best_rank
def parse_target_heights(raw: str) -> list[int]:
values = []
for part in raw.split(","):
part = part.strip()
if part:
values.append(int(part))
return values or [96]
def make_ocr():
add_nvidia_dll_dirs()
from paddleocr import PaddleOCR
return PaddleOCR(
lang="en",
use_doc_orientation_classify=False,
use_doc_unwarping=False,
use_textline_orientation=False,
text_rec_score_thresh=0.0,
)
def predict_one(ocr: Any, image: np.ndarray) -> list[tuple[str, float]]:
if hasattr(ocr, "predict"):
result = ocr.predict(image)
else:
result = ocr.ocr(image, det=False, cls=False)
return extract_candidates(result)
def handle_request(ocr: Any, request: dict[str, Any]) -> dict[str, Any]:
image_path = Path(str(request["image_path"]))
target_heights = parse_target_heights(str(request.get("target_heights", "96")))
variant_set = str(request.get("variant_set", "fast")).strip().lower()
expected_digits = int(request.get("expected_digits", 6))
min_votes = int(request.get("min_votes", 2))
min_confidence = float(request.get("min_confidence", 0.70))
image = cv2.imread(str(image_path), cv2.IMREAD_COLOR)
if image is None:
return {
"ok": False,
"text": "",
"raw_text": "",
"confidence": 0.0,
"votes": 0,
"variant": "",
"reason": f"unreadable_image:{image_path}",
"candidates": [],
}
candidates: list[Candidate] = []
ocr_variants = filter_variants(build_variants(image, target_heights), variant_set)
for variant_name, variant_image in ocr_variants.items():
for text, score in predict_one(ocr, variant_image):
candidates.append(Candidate(text=text, score=score, variant=variant_name))
text, confidence, votes, variant, rank = choose_best(candidates, expected_digits)
accepted = bool(text) and len(text) == expected_digits and votes >= min_votes and confidence >= min_confidence
sorted_candidates = sorted(candidates, key=lambda item: item.score, reverse=True)
raw_text = " | ".join(f"{item.text}:{item.score:.3f}:{item.variant}" for item in sorted_candidates[:18])
return {
"ok": accepted,
"text": text if accepted else "",
"best_text": text,
"raw_text": raw_text,
"confidence": confidence,
"votes": votes,
"variant": variant,
"rank": rank,
"reason": "ok" if accepted else "low_consensus_or_invalid_length",
"candidates": [
{"text": item.text, "score": item.score, "variant": item.variant}
for item in sorted_candidates[:18]
],
}
def main() -> int:
ocr = make_ocr()
print(json.dumps({"ready": True}), flush=True)
for line in sys.stdin:
line = line.strip()
if not line:
continue
if line == "__quit__":
break
try:
request = json.loads(line)
response = handle_request(ocr, request)
except Exception as exc:
response = {
"ok": False,
"text": "",
"raw_text": "",
"confidence": 0.0,
"votes": 0,
"variant": "",
"reason": f"worker_error:{exc}",
"candidates": [],
}
print(json.dumps(response, ensure_ascii=True), flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())

918
flywms_wms_server.py Normal file
View File

@@ -0,0 +1,918 @@
import argparse
import asyncio
import configparser
import ctypes
import json
import random
import re
import shutil
import subprocess
import threading
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
import cv2
import numpy as np
import uvicorn
from fastapi import FastAPI, File, Form, UploadFile
try:
import dearpygui.dearpygui as dpg
except ModuleNotFoundError:
dpg = None
DEFAULT_CONFIG_PATH = "flywms_navigation.ini"
CUDA_MIN_PIXELS = 640 * 360
def opencv_cuda_available() -> bool:
try:
return hasattr(cv2, "cuda") and cv2.cuda.getCudaEnabledDeviceCount() > 0
except cv2.error:
return False
OPENCV_CUDA_AVAILABLE = opencv_cuda_available()
def cuda_resize(
image: np.ndarray,
size: tuple[int, int],
interpolation: int = cv2.INTER_LINEAR,
min_pixels: int = CUDA_MIN_PIXELS,
) -> np.ndarray:
if not OPENCV_CUDA_AVAILABLE or image.size < min_pixels:
return cv2.resize(image, size, interpolation=interpolation)
try:
gpu = cv2.cuda_GpuMat()
gpu.upload(image)
return cv2.cuda.resize(gpu, size, interpolation=interpolation).download()
except cv2.error:
return cv2.resize(image, size, interpolation=interpolation)
def cuda_cvt_color(
image: np.ndarray,
code: int,
min_pixels: int = CUDA_MIN_PIXELS,
) -> np.ndarray:
if not OPENCV_CUDA_AVAILABLE or image.size < min_pixels:
return cv2.cvtColor(image, code)
try:
gpu = cv2.cuda_GpuMat()
gpu.upload(image)
return cv2.cuda.cvtColor(gpu, code).download()
except cv2.error:
return cv2.cvtColor(image, code)
app = FastAPI(title="FlyWMS Demo WMS Server")
server_state = {
"received_dir": "wms_received",
"fake_ack_mode": "always-ack",
"fake_processing_sec": 0.5,
"ui_enabled": True,
"ocr_enabled": True,
"ocr_mode": "paddleocr",
"tesseract_cmd": "",
"paddle_python": r"C:\devel\yolo-ocr\.venv-paddle-cpu\Scripts\python.exe",
"paddle_worker_script": str(Path(__file__).with_name("flywms_paddleocr_worker.py")),
"paddle_target_heights": "96",
"paddle_variant_set": "fast",
"paddle_expected_digits": 6,
"paddle_min_votes": 2,
"paddle_min_confidence": 0.70,
"fake_ocr_prefix": "UDC",
"undetermined_code_text": "udc non determinato",
"fake_ocr_delay_sec": 0.2,
"operator": "udrone",
"site": "demo-magazzino",
"counter": 0,
}
state_lock = threading.Lock()
ui_lock = threading.Lock()
ui_state = {
"image": None,
"payload_lines": ["In attesa di payload WMS"],
"updated": False,
"stop": False,
}
@dataclass(frozen=True)
class OcrServerResult:
text: str
raw_text: str
confidence: float
backend: str
fallback_used: bool
votes: int = 0
variant: str = ""
class PaddleOcrWorker:
def __init__(
self,
python_path: str,
script_path: str,
target_heights: str,
variant_set: str,
expected_digits: int,
min_votes: int,
min_confidence: float,
) -> None:
self.python_path = python_path
self.script_path = script_path
self.target_heights = target_heights
self.variant_set = variant_set
self.expected_digits = expected_digits
self.min_votes = min_votes
self.min_confidence = min_confidence
self.lock = threading.Lock()
self.process: subprocess.Popen[str] | None = None
def close(self) -> None:
with self.lock:
process = self.process
self.process = None
if process is None:
return
try:
if process.stdin:
process.stdin.write("__quit__\n")
process.stdin.flush()
except Exception:
pass
try:
process.wait(timeout=2.0)
except subprocess.TimeoutExpired:
process.kill()
def predict(self, image_path: Path) -> dict[str, object]:
with self.lock:
process = self._ensure_process()
if process.stdin is None or process.stdout is None:
raise RuntimeError("worker stdio non disponibile")
request = {
"image_path": str(image_path),
"target_heights": self.target_heights,
"variant_set": self.variant_set,
"expected_digits": self.expected_digits,
"min_votes": self.min_votes,
"min_confidence": self.min_confidence,
}
process.stdin.write(json.dumps(request, ensure_ascii=True) + "\n")
process.stdin.flush()
line = process.stdout.readline()
if not line:
self.process = None
raise RuntimeError("worker PaddleOCR terminato")
return json.loads(line)
def _ensure_process(self) -> subprocess.Popen[str]:
if self.process is not None and self.process.poll() is None:
return self.process
python_path = Path(self.python_path)
script_path = Path(self.script_path)
if not python_path.exists():
raise RuntimeError(f"python PaddleOCR non trovato: {python_path}")
if not script_path.exists():
raise RuntimeError(f"worker PaddleOCR non trovato: {script_path}")
self.process = subprocess.Popen(
[str(python_path), str(script_path)],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
encoding="utf-8",
errors="replace",
creationflags=subprocess.CREATE_NO_WINDOW if hasattr(subprocess, "CREATE_NO_WINDOW") else 0,
)
assert self.process.stdout is not None
ready_line = self.process.stdout.readline()
if not ready_line:
raise RuntimeError("worker PaddleOCR non avviato")
ready = json.loads(ready_line)
if not ready.get("ready"):
raise RuntimeError(f"worker PaddleOCR risposta inattesa: {ready}")
return self.process
_paddle_worker: PaddleOcrWorker | None = None
_paddle_worker_lock = threading.Lock()
def log(msg: str) -> None:
print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
def load_server_config(path_str: str) -> dict[str, object]:
defaults: dict[str, object] = {
"wms_server_host": "0.0.0.0",
"wms_server_port": 8088,
"wms_received_dir": "wms_received",
"wms_fake_ack_mode": "always-ack",
"wms_fake_processing_sec": 0.5,
"wms_ui_enabled": True,
"wms_ocr_enabled": True,
"wms_ocr_mode": "paddleocr",
"wms_tesseract_cmd": "",
"wms_paddle_python": r"C:\devel\yolo-ocr\.venv-paddle-cpu\Scripts\python.exe",
"wms_paddle_worker_script": str(Path(__file__).with_name("flywms_paddleocr_worker.py")),
"wms_paddle_target_heights": "96",
"wms_paddle_variant_set": "fast",
"wms_paddle_expected_digits": 6,
"wms_paddle_min_votes": 2,
"wms_paddle_min_confidence": 0.70,
"wms_fake_ocr_prefix": "UDC",
"wms_undetermined_code_text": "udc non determinato",
"wms_fake_ocr_delay_sec": 0.2,
"wms_operator": "udrone",
"wms_site": "demo-magazzino",
}
path = Path(path_str)
if not path.exists():
return defaults
parser = configparser.ConfigParser()
parser.read(path, encoding="utf-8")
section = parser["navigation"] if parser.has_section("navigation") else {}
for key, default_value in defaults.items():
if key not in section:
continue
if isinstance(default_value, bool):
defaults[key] = parser.getboolean("navigation", key, fallback=default_value)
elif isinstance(default_value, int):
defaults[key] = parser.getint("navigation", key, fallback=default_value)
elif isinstance(default_value, float):
defaults[key] = parser.getfloat("navigation", key, fallback=default_value)
else:
defaults[key] = section.get(key, str(default_value)).strip()
return defaults
def parse_args():
pre = argparse.ArgumentParser(add_help=False)
pre.add_argument("--config", default=DEFAULT_CONFIG_PATH, help="File configurazione INI")
pre_args, _ = pre.parse_known_args()
defaults = load_server_config(pre_args.config)
ap = argparse.ArgumentParser(parents=[pre])
ap.add_argument("--host", default=defaults["wms_server_host"], help="Host bind server")
ap.add_argument("--port", type=int, default=defaults["wms_server_port"], help="Porta server")
ap.add_argument("--received-dir", default=defaults["wms_received_dir"], help="Directory upload ricevuti")
ap.add_argument("--ui-backend", choices=["auto", "opencv", "dearpygui"], default="dearpygui",
help="Backend UI server WMS")
ap.add_argument(
"--fake-ack-mode",
choices=["always-ack", "always-nack", "alternate", "random"],
default=defaults["wms_fake_ack_mode"],
help="Modalita risposta WMS demo",
)
ap.add_argument(
"--fake-processing-sec",
type=float,
default=defaults["wms_fake_processing_sec"],
help="Ritardo elaborazione finta",
)
ap.add_argument("--ui-enabled", action="store_true", default=defaults["wms_ui_enabled"], help="Mostra finestre OpenCV server")
ap.add_argument("--no-ui", action="store_false", dest="ui_enabled", help="Disabilita finestre OpenCV server")
ap.add_argument("--ocr-enabled", action="store_true", default=defaults["wms_ocr_enabled"], help="Esegue OCR reale se disponibile")
ap.add_argument("--no-ocr", action="store_false", dest="ocr_enabled", help="Disabilita OCR reale e usa fallback demo")
ap.add_argument("--ocr-mode", choices=["paddleocr", "easyocr", "tesseract", "fake"], default=defaults["wms_ocr_mode"], help="Motore OCR server")
ap.add_argument("--tesseract-cmd", default=defaults["wms_tesseract_cmd"], help="Percorso esplicito tesseract.exe")
ap.add_argument("--paddle-python", default=defaults["wms_paddle_python"], help="Python del virtualenv PaddleOCR")
ap.add_argument("--paddle-worker-script", default=defaults["wms_paddle_worker_script"], help="Script worker PaddleOCR")
ap.add_argument("--paddle-target-heights", default=defaults["wms_paddle_target_heights"], help="Altezze resize PaddleOCR, es. 96 oppure 64,96,128")
ap.add_argument("--paddle-variant-set", choices=["fast", "balanced", "full"], default=defaults["wms_paddle_variant_set"], help="Numero varianti preprocessing PaddleOCR")
ap.add_argument("--paddle-expected-digits", type=int, default=defaults["wms_paddle_expected_digits"], help="Numero cifre atteso codice UDC")
ap.add_argument("--paddle-min-votes", type=int, default=defaults["wms_paddle_min_votes"], help="Consenso minimo varianti PaddleOCR")
ap.add_argument("--paddle-min-confidence", type=float, default=defaults["wms_paddle_min_confidence"], help="Confidenza minima PaddleOCR")
ap.add_argument("--fake-ocr-prefix", default=defaults["wms_fake_ocr_prefix"], help="Prefisso fallback OCR demo")
ap.add_argument("--undetermined-code-text", default=defaults["wms_undetermined_code_text"], help="Testo usato se OCR non determina il codice")
ap.add_argument("--fake-ocr-delay-sec", type=float, default=defaults["wms_fake_ocr_delay_sec"], help="Ritardo OCR demo/fallback")
ap.add_argument("--operator", default=defaults["wms_operator"], help="Operatore WMS demo")
ap.add_argument("--site", default=defaults["wms_site"], help="Sito/magazzino demo")
return ap.parse_args()
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.post("/api/v1/navigation-snapshot")
async def receive_navigation_snapshot(
metadata: str = Form(...),
label_image: UploadFile = File(...),
gaylord_image: UploadFile | None = File(None),
) -> dict[str, object]:
started = time.perf_counter()
try:
meta = json.loads(metadata)
except json.JSONDecodeError:
meta = {"raw_metadata": metadata}
with state_lock:
server_state["counter"] = int(server_state["counter"]) + 1
counter = int(server_state["counter"])
request_id = str(meta.get("request_id") or f"server-{counter:06d}")
snapshot_id = meta.get("snapshot_id", counter)
received_dir = Path(str(server_state["received_dir"]))
request_dir = received_dir / f"{counter:06d}_{safe_name(request_id)}"
request_dir.mkdir(parents=True, exist_ok=True)
label_path = request_dir / safe_upload_name(label_image.filename, "label.jpg")
label_bytes = await label_image.read()
label_path.write_bytes(label_bytes)
gaylord_path = None
gaylord_bytes = b""
if gaylord_image is not None:
gaylord_path = request_dir / safe_upload_name(gaylord_image.filename, "gaylord.jpg")
gaylord_bytes = await gaylord_image.read()
gaylord_path.write_bytes(gaylord_bytes)
(request_dir / "metadata.json").write_text(
json.dumps(meta, indent=2, ensure_ascii=True),
encoding="utf-8",
)
image = cv2.imdecode(np.frombuffer(label_bytes, dtype=np.uint8), cv2.IMREAD_COLOR)
ocr_result = await asyncio.to_thread(run_server_ocr, image, int(snapshot_id), label_path)
fake_processing_sec = float(server_state["fake_processing_sec"])
if fake_processing_sec > 0:
await asyncio.sleep(fake_processing_sec)
status = choose_status(str(server_state["fake_ack_mode"]), counter)
udc_code = ocr_result.text if status == "ACK" else ""
message = (
f"codice valido su WMS: {udc_code}"
if status == "ACK"
else f"codice non riconosciuto: {ocr_result.text}"
)
processing_ms = (time.perf_counter() - started) * 1000.0
received_at = datetime.now(timezone.utc).isoformat()
wms_payload = {
"request_id": request_id,
"client_id": meta.get("client_id", ""),
"site": server_state["site"],
"operator": server_state["operator"],
"received_at": received_at,
"snapshot_id": snapshot_id,
"position": meta.get("simulated_position", ""),
"track_id": meta.get("track_id", ""),
"udc_code": udc_code,
"ocr_raw_text": ocr_result.raw_text,
"ocr_confidence": ocr_result.confidence,
"ocr_backend": ocr_result.backend,
"ocr_fallback_used": ocr_result.fallback_used,
"ocr_votes": ocr_result.votes,
"ocr_variant": ocr_result.variant,
"validation_status": status,
"validation_message": message,
"label_bbox": meta.get("label_bbox", []),
"gaylord_bbox": meta.get("gaylord_bbox", []),
"movement_vector_px": meta.get("movement_vector_px", {}),
"label_image_path": str(label_path),
"gaylord_image_path": str(gaylord_path) if gaylord_path else "",
}
(request_dir / "wms_payload.json").write_text(
json.dumps(wms_payload, indent=2, ensure_ascii=True),
encoding="utf-8",
)
update_ui_state(image, wms_payload)
log(
f"received request_id={request_id} snapshot={snapshot_id} "
f"label_bytes={len(label_bytes)} gaylord_bytes={len(gaylord_bytes)} "
f"ocr='{ocr_result.text}' status={status}"
)
return {
"status": status,
"request_id": request_id,
"message": message,
"ocr_text": udc_code,
"ocr_raw_text": ocr_result.raw_text,
"ocr_confidence": ocr_result.confidence,
"ocr_backend": ocr_result.backend,
"ocr_fallback_used": ocr_result.fallback_used,
"ocr_votes": ocr_result.votes,
"ocr_variant": ocr_result.variant,
"processing_ms": round(processing_ms, 1),
"saved_dir": str(request_dir),
"wms_payload_path": str(request_dir / "wms_payload.json"),
"label_image_path": str(label_path),
"gaylord_image_path": str(gaylord_path) if gaylord_path else "",
}
def run_server_ocr(image: np.ndarray | None, snapshot_id: int, label_path: Path | None = None) -> OcrServerResult:
fake_delay = float(server_state["fake_ocr_delay_sec"])
if fake_delay > 0:
time.sleep(fake_delay)
fallback = str(server_state["undetermined_code_text"])
if image is None:
return OcrServerResult(fallback, "", 0.0, "fallback", True)
if not bool(server_state["ocr_enabled"]) or str(server_state["ocr_mode"]) == "fake":
return OcrServerResult(fallback, fallback, 1.0, "fake", True)
if str(server_state["ocr_mode"]) == "paddleocr":
if label_path is None:
return OcrServerResult(fallback, "", 0.0, "paddleocr-missing-path", True)
return run_paddleocr_ocr(label_path, fallback)
if str(server_state["ocr_mode"]) == "tesseract":
return run_tesseract_ocr(image, fallback)
try:
reader = get_easyocr_reader()
ocr_input = preprocess_label_for_ocr(image)
results = reader.readtext(
ocr_input,
allowlist="0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-",
detail=1,
paragraph=False,
)
except Exception as exc:
log(f"OCR fallback: {exc}")
return OcrServerResult(fallback, "", 0.0, "easyocr-error", True)
raw_parts: list[str] = []
best_text = ""
best_conf = 0.0
for item in results:
text = str(item[1]).strip()
conf = float(item[2]) if len(item) > 2 else 0.0
if text:
raw_parts.append(text)
candidate = normalize_ocr_code(text)
if candidate and conf >= best_conf:
best_text = candidate
best_conf = conf
raw_text = " ".join(raw_parts)
if not best_text:
digits = re.sub(r"\D+", "", raw_text)
if digits:
best_text = digits
best_conf = max(best_conf, 0.01)
if best_text:
return OcrServerResult(best_text, raw_text, best_conf, "easyocr", False)
return OcrServerResult(fallback, raw_text, best_conf, "easyocr-fallback", True)
def get_paddle_worker() -> PaddleOcrWorker:
global _paddle_worker
with _paddle_worker_lock:
if _paddle_worker is None:
_paddle_worker = PaddleOcrWorker(
python_path=str(server_state["paddle_python"]),
script_path=str(server_state["paddle_worker_script"]),
target_heights=str(server_state["paddle_target_heights"]),
variant_set=str(server_state["paddle_variant_set"]),
expected_digits=int(server_state["paddle_expected_digits"]),
min_votes=int(server_state["paddle_min_votes"]),
min_confidence=float(server_state["paddle_min_confidence"]),
)
return _paddle_worker
def close_paddle_worker() -> None:
global _paddle_worker
with _paddle_worker_lock:
worker = _paddle_worker
_paddle_worker = None
if worker is not None:
worker.close()
def run_paddleocr_ocr(label_path: Path, fallback: str) -> OcrServerResult:
try:
response = get_paddle_worker().predict(label_path)
except Exception as exc:
log(f"PaddleOCR fallback: {exc}")
close_paddle_worker()
return OcrServerResult(fallback, "", 0.0, "paddleocr-error", True)
text = str(response.get("text") or "")
best_text = str(response.get("best_text") or text)
raw_text = str(response.get("raw_text") or best_text)
confidence = float(response.get("confidence") or 0.0)
votes = int(response.get("votes") or 0)
variant = str(response.get("variant") or "")
if response.get("ok") and text:
return OcrServerResult(text, raw_text, confidence, "paddleocr", False, votes=votes, variant=variant)
reason = str(response.get("reason") or "fallback")
raw_with_reason = f"{raw_text} [{reason}]".strip()
return OcrServerResult(fallback, raw_with_reason, confidence, "paddleocr-fallback", True, votes=votes, variant=variant)
def run_tesseract_ocr(image: np.ndarray, fallback: str) -> OcrServerResult:
try:
import pytesseract
except Exception as exc:
log(f"Tesseract fallback: pytesseract non disponibile: {exc}")
return OcrServerResult(fallback, "", 0.0, "tesseract-unavailable", True)
tesseract_cmd = str(server_state.get("tesseract_cmd") or "").strip()
if tesseract_cmd:
if not Path(tesseract_cmd).exists():
log(f"Tesseract fallback: binario non trovato: {tesseract_cmd}")
return OcrServerResult(fallback, "", 0.0, "tesseract-missing", True)
pytesseract.pytesseract.tesseract_cmd = tesseract_cmd
elif shutil.which("tesseract") is None:
log("Tesseract fallback: tesseract.exe non trovato nel PATH")
return OcrServerResult(fallback, "", 0.0, "tesseract-missing", True)
variants = build_tesseract_variants(image)
candidates: list[tuple[str, float, str]] = []
config = "--psm 7 --oem 3 -c tessedit_char_whitelist=0123456789"
for name, variant in variants:
try:
data = pytesseract.image_to_data(
variant,
config=config,
output_type=pytesseract.Output.DICT,
)
except Exception as exc:
log(f"Tesseract fallback su variante {name}: {exc}")
return OcrServerResult(fallback, "", 0.0, "tesseract-error", True)
texts = [text.strip() for text in data.get("text", []) if text and text.strip()]
confs: list[float] = []
for conf in data.get("conf", []):
try:
value = float(conf)
except (TypeError, ValueError):
continue
if value >= 0:
confs.append(value / 100.0)
raw = "".join(texts)
digits = re.sub(r"\D+", "", raw)
confidence = sum(confs) / len(confs) if confs else 0.0
if digits:
candidates.append((digits, confidence, name))
if not candidates:
return OcrServerResult(fallback, "", 0.0, "tesseract-fallback", True)
votes: dict[str, list[float]] = {}
for digits, confidence, _ in candidates:
votes.setdefault(digits, []).append(confidence)
best_digits, best_confs = max(
votes.items(),
key=lambda item: (len(item[1]), sum(item[1]) / max(len(item[1]), 1)),
)
consensus_count = len(best_confs)
avg_conf = sum(best_confs) / consensus_count
raw_text = "; ".join(f"{digits}@{conf:.2f}/{name}" for digits, conf, name in candidates)
if consensus_count < 2 or avg_conf < 0.45:
return OcrServerResult(fallback, raw_text, avg_conf, "tesseract-ambiguous", True)
return OcrServerResult(best_digits, raw_text, avg_conf, "tesseract", False)
def build_tesseract_variants(image: np.ndarray) -> list[tuple[str, np.ndarray]]:
h, w = image.shape[:2]
scale = 4.0 if w < 500 else 2.0
resized = cuda_resize(image, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_CUBIC)
gray = cuda_cvt_color(resized, cv2.COLOR_BGR2GRAY)
gray = cv2.bilateralFilter(gray, 7, 45, 45)
sharpen = cv2.addWeighted(gray, 1.6, cv2.GaussianBlur(gray, (0, 0), 1.2), -0.6, 0)
_, otsu = cv2.threshold(sharpen, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
adaptive = cv2.adaptiveThreshold(
sharpen,
255,
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY,
31,
7,
)
return [
("gray", gray),
("sharpen", sharpen),
("otsu", otsu),
("adaptive", adaptive),
("otsu_inv", cv2.bitwise_not(otsu)),
]
_easyocr_reader = None
_easyocr_lock = threading.Lock()
def get_easyocr_reader():
global _easyocr_reader
with _easyocr_lock:
if _easyocr_reader is None:
import easyocr
_easyocr_reader = easyocr.Reader(["en"], gpu=False, verbose=False)
return _easyocr_reader
def preprocess_label_for_ocr(image: np.ndarray) -> np.ndarray:
h, w = image.shape[:2]
scale = 3.0 if w < 450 else 2.0
resized = cuda_resize(image, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_CUBIC)
gray = cuda_cvt_color(resized, cv2.COLOR_BGR2GRAY)
gray = cv2.bilateralFilter(gray, 7, 45, 45)
return cuda_cvt_color(gray, cv2.COLOR_GRAY2BGR)
def normalize_ocr_code(text: str) -> str:
compact = re.sub(r"[^0-9A-Za-z-]+", "", text).strip("-")
digits = re.sub(r"\D+", "", compact)
return digits if digits else compact
def update_ui_state(image: np.ndarray | None, payload: dict[str, object]) -> None:
if not bool(server_state["ui_enabled"]):
return
lines = [
"ULTIMO PAYLOAD WMS",
f"request_id: {payload.get('request_id', '')}",
f"client_id: {payload.get('client_id', '')}",
f"site: {payload.get('site', '')}",
f"operator: {payload.get('operator', '')}",
f"received_at: {payload.get('received_at', '')}",
f"snapshot_id: {payload.get('snapshot_id', '')}",
f"position: {payload.get('position', '')}",
f"track_id: {payload.get('track_id', '')}",
f"udc_code: {payload.get('udc_code', '')}",
f"ocr_raw: {payload.get('ocr_raw_text', '')}",
f"ocr_backend: {payload.get('ocr_backend', '')}",
f"ocr_fallback: {payload.get('ocr_fallback_used', '')}",
f"ocr_votes: {payload.get('ocr_votes', '')}",
f"ocr_variant: {payload.get('ocr_variant', '')}",
f"status: {payload.get('validation_status', '')}",
f"label_bbox: {payload.get('label_bbox', '')}",
f"movement: {payload.get('movement_vector_px', '')}",
]
with ui_lock:
ui_state["image"] = None if image is None else image.copy()
ui_state["payload_lines"] = lines
ui_state["updated"] = True
def choose_status(mode: str, counter: int) -> str:
if mode == "always-nack":
return "NACK"
if mode == "alternate":
return "ACK" if counter % 2 == 1 else "NACK"
if mode == "random":
return "ACK" if random.random() >= 0.5 else "NACK"
return "ACK"
def safe_upload_name(filename: str | None, fallback: str) -> str:
name = Path(filename or fallback).name
return safe_name(name) or fallback
def safe_name(value: str) -> str:
allowed = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-"
return "".join(ch if ch in allowed else "_" for ch in value)[:160]
def choose_ui_backend(requested: str) -> str:
if requested == "opencv":
return "opencv"
if requested == "dearpygui":
if dpg is None:
raise RuntimeError("DearPyGUI non disponibile")
return "dearpygui"
if dpg is not None:
return "dearpygui"
return "opencv"
def placeholder_frame(width: int, height: int, text: str) -> np.ndarray:
canvas = np.full((height, width, 3), 235, dtype=np.uint8)
cv2.putText(
canvas,
text,
(24, height // 2),
cv2.FONT_HERSHEY_SIMPLEX,
0.8,
(30, 30, 30),
2,
cv2.LINE_AA,
)
return canvas
def frame_to_texture_data(frame: np.ndarray, target_w: int, target_h: int) -> list[float]:
src_h, src_w = frame.shape[:2]
if src_h <= 0 or src_w <= 0:
canvas = np.zeros((target_h, target_w, 3), dtype=np.uint8)
else:
scale = min(target_w / float(src_w), target_h / float(src_h))
draw_w = max(1, int(round(src_w * scale)))
draw_h = max(1, int(round(src_h * scale)))
resized = cv2.resize(frame, (draw_w, draw_h), interpolation=cv2.INTER_AREA if scale < 1.0 else cv2.INTER_LINEAR)
canvas = np.full((target_h, target_w, 3), 18, dtype=np.uint8)
x0 = (target_w - draw_w) // 2
y0 = (target_h - draw_h) // 2
canvas[y0:y0 + draw_h, x0:x0 + draw_w] = resized
rgba = cv2.cvtColor(canvas, cv2.COLOR_BGR2RGBA)
return (rgba.astype(np.float32).ravel() / 255.0).tolist()
def restore_window_by_title(title: str) -> None:
if not hasattr(ctypes, "windll"):
return
user32 = ctypes.windll.user32
hwnd = user32.FindWindowW(None, title)
if hwnd:
user32.ShowWindow(hwnd, 9) # SW_RESTORE
user32.SetForegroundWindow(hwnd)
def server_ui_loop() -> None:
try:
cv2.namedWindow("wms immagine ricevuta", cv2.WINDOW_NORMAL)
cv2.namedWindow("wms payload", cv2.WINDOW_NORMAL)
except cv2.error as exc:
log(f"UI OpenCV disabilitata: highgui non disponibile ({exc})")
with ui_lock:
ui_state["stop"] = True
return
cv2.resizeWindow("wms immagine ricevuta", 640, 360)
cv2.resizeWindow("wms payload", 900, 560)
cv2.moveWindow("wms immagine ricevuta", 40, 40)
cv2.moveWindow("wms payload", 720, 40)
while True:
with ui_lock:
if ui_state["stop"]:
break
image = None if ui_state["image"] is None else ui_state["image"].copy()
lines = list(ui_state["payload_lines"])
if image is None:
image_display = np.full((360, 640, 3), 240, dtype=np.uint8)
cv2.putText(
image_display,
"In attesa immagine etichetta",
(30, 180),
cv2.FONT_HERSHEY_SIMPLEX,
0.9,
(0, 0, 0),
2,
cv2.LINE_AA,
)
else:
image_display = resize_preview(image, 640)
cv2.imshow("wms immagine ricevuta", image_display)
cv2.imshow("wms payload", draw_payload_window(lines))
key = cv2.waitKey(100) & 0xFF
if key in (27, ord("q")):
with ui_lock:
ui_state["stop"] = True
break
cv2.destroyWindow("wms immagine ricevuta")
cv2.destroyWindow("wms payload")
def server_dpg_ui_loop() -> None:
if dpg is None:
log("UI DearPyGUI non disponibile, impossibile avviare il monitor WMS")
with ui_lock:
ui_state["stop"] = True
return
image_texture = "wms_image_texture"
payload_text = "wms_payload_text"
status_text = "wms_status_text"
image_w = 720
image_h = 420
def create_texture(tag: str, data: list[float], width: int, height: int) -> None:
dpg.add_dynamic_texture(width=width, height=height, default_value=data, tag=tag)
def set_texture(frame: np.ndarray) -> None:
dpg.set_value(image_texture, frame_to_texture_data(frame, image_w, image_h))
dpg.create_context()
with dpg.texture_registry(show=False):
create_texture(
image_texture,
frame_to_texture_data(placeholder_frame(image_w, image_h, "In attesa immagine etichetta"), image_w, image_h),
image_w,
image_h,
)
with dpg.window(label="FlyWMS WMS Server", width=1450, height=900):
with dpg.group(horizontal=True):
with dpg.child_window(width=760, height=840, border=True):
dpg.add_text("Ultima immagine ricevuta")
dpg.add_image(image_texture)
with dpg.child_window(width=640, height=840, border=True):
dpg.add_text("Stato")
dpg.add_text("Server WMS in attesa payload", tag=status_text, wrap=600)
dpg.add_separator()
dpg.add_text("Payload")
dpg.add_input_text(tag=payload_text, multiline=True, readonly=True, width=600, height=720, default_value="In attesa di payload WMS")
dpg.create_viewport(title="FlyWMS WMS Server", width=1450, height=900)
dpg.setup_dearpygui()
dpg.show_viewport()
time.sleep(0.05)
restore_window_by_title("FlyWMS WMS Server")
try:
while dpg.is_dearpygui_running():
with ui_lock:
stop = bool(ui_state["stop"])
image = None if ui_state["image"] is None else ui_state["image"].copy()
lines = list(ui_state["payload_lines"])
if stop:
break
if image is not None:
set_texture(image)
dpg.set_value(status_text, f"Richieste ricevute: {server_state['counter']}")
dpg.set_value(payload_text, "\n".join(lines))
dpg.render_dearpygui_frame()
time.sleep(0.03)
finally:
dpg.destroy_context()
def resize_preview(frame: np.ndarray, max_width: int) -> np.ndarray:
h, w = frame.shape[:2]
if max_width <= 0 or w <= max_width:
return frame
scale = max_width / float(w)
return cuda_resize(frame, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_LINEAR)
def draw_payload_window(lines: list[str]) -> np.ndarray:
canvas = np.full((560, 900, 3), 245, dtype=np.uint8)
y = 42
for idx, line in enumerate(lines[:18]):
color = (0, 0, 160) if idx == 0 else (0, 0, 0)
scale = 0.9 if idx == 0 else 0.63
thickness = 2 if idx == 0 else 1
cv2.putText(
canvas,
str(line)[:110],
(24, y),
cv2.FONT_HERSHEY_SIMPLEX,
scale,
color,
thickness,
cv2.LINE_AA,
)
y += 34 if idx == 0 else 30
return canvas
def main() -> int:
args = parse_args()
ui_backend = choose_ui_backend(args.ui_backend) if args.ui_enabled else "disabled"
server_state["received_dir"] = args.received_dir
server_state["fake_ack_mode"] = args.fake_ack_mode
server_state["fake_processing_sec"] = args.fake_processing_sec
server_state["ui_enabled"] = args.ui_enabled
server_state["ocr_enabled"] = args.ocr_enabled
server_state["ocr_mode"] = args.ocr_mode
server_state["tesseract_cmd"] = args.tesseract_cmd
server_state["paddle_python"] = args.paddle_python
server_state["paddle_worker_script"] = args.paddle_worker_script
server_state["paddle_target_heights"] = args.paddle_target_heights
server_state["paddle_variant_set"] = args.paddle_variant_set
server_state["paddle_expected_digits"] = args.paddle_expected_digits
server_state["paddle_min_votes"] = args.paddle_min_votes
server_state["paddle_min_confidence"] = args.paddle_min_confidence
server_state["fake_ocr_prefix"] = args.fake_ocr_prefix
server_state["undetermined_code_text"] = args.undetermined_code_text
server_state["fake_ocr_delay_sec"] = args.fake_ocr_delay_sec
server_state["operator"] = args.operator
server_state["site"] = args.site
Path(args.received_dir).mkdir(parents=True, exist_ok=True)
log(
f"FlyWMS WMS demo server host={args.host} port={args.port} "
f"mode={args.fake_ack_mode} received_dir={args.received_dir}"
)
log(f"OpenCV CUDA: {'attivo' if OPENCV_CUDA_AVAILABLE else 'non disponibile'}")
log(f"UI backend: {ui_backend}")
ui_thread = None
if args.ui_enabled:
ui_target = server_dpg_ui_loop if ui_backend == "dearpygui" else server_ui_loop
ui_thread = threading.Thread(target=ui_target, name="wms-server-ui", daemon=True)
ui_thread.start()
try:
uvicorn.run(app, host=args.host, port=args.port, log_level="info")
finally:
close_paddle_worker()
with ui_lock:
ui_state["stop"] = True
if ui_thread is not None:
ui_thread.join(timeout=2.0)
return 0
if __name__ == "__main__":
raise SystemExit(main())

255
handoff.md Normal file
View File

@@ -0,0 +1,255 @@
# FlyWMS handoff
Data: 2026-05-18
## Stato repository
- Branch: `master`
- Remote: `origin` su Gitea
- Commit da creare per questa milestone: `pipeline in linea single thread`
- Tag git previsto per questa milestone: `pipeline-in-linea-single-thread`
## Stato funzionale attuale
La base di lavoro e' ancora una pipeline sostanzialmente single-thread, con logica completa di navigazione/demo in Python.
Componenti principali presenti:
- [flywms_navigation.py](C:/devel/flywms/flywms_navigation.py)
- detector Ultralytics YOLO
- tracking geometrico
- logica snapshot gaylord + etichetta
- macchina a stati demo per movimento verso etichetta, stabilizzazione, scatto, ritorno, attesa WMS
- client WMS asincrono
- log prestazioni dettagliato per frame
- profilo `benchmark`
- [flywms_wms_server.py](C:/devel/flywms/flywms_wms_server.py)
- server demo FastAPI
- ricezione payload e immagini
- OCR lato server
- salvataggio payload/immagini ricevute
- [flywms_paddleocr_worker.py](C:/devel/flywms/flywms_paddleocr_worker.py)
- worker persistente per PaddleOCR
- [flywms_navigation_gui.py](C:/devel/flywms/flywms_navigation_gui.py)
- shell DearPyGUI separata dalla logica principale
## Stato GPU / librerie
Verificato su questo PC:
- OpenCV con CUDA e GUI Win32 attivi
- cuDNN attivo
- YOLO/Ultralytics su GPU RTX 3050
- `yolo_half = true` supportato
PaddleOCR al momento resta nel worker dedicato e non e' il focus della prossima rifattorizzazione.
## Configurazione importante
File: [flywms_navigation.ini](C:/devel/flywms/flywms_navigation.ini)
Valori chiave attuali:
- `ultralytics_device = 0`
- `yolo_half = true`
- `preview_fps = 24.0`
- `yolo_fps = 15.0`
- `benchmark_mode = false`
- `benchmark_preview_fps = 30.0`
- `perf_log_path = tempistiche.txt`
Il video di lavoro locale usato durante i test recenti e' `testhd2_edit.mp4`.
Nota: i file video locali non sono necessariamente da committare.
## Log e benchmark
### Log demo con UI
File:
- [tempistiche.txt](C:/devel/flywms/tempistiche.txt)
Risultati principali gia' analizzati:
- video sorgente: circa `10.97 min` a `30 fps`
- run demo completo molto piu' lento a causa di:
- UI OpenCV pesante
- pause snapshot
- attese WMS
Numeri rilevanti del run demo:
- `demo_active_s = 1092.85 s`
- `demo_draw_ui_s = 597.18 s`
Conclusione:
- la UI OpenCV e il rendering accessorio pesano moltissimo
- la pipeline di calcolo non deve piu' essere accoppiata alla UI in questo modo
### Benchmark headless
File:
- [tempistiche-benchmark.txt](C:/devel/flywms/tempistiche-benchmark.txt)
Profilo usato:
```powershell
python flywms_navigation.py --benchmark-mode
```
Caratteristiche del profilo benchmark:
- nessuna finestra OpenCV
- `preview_target = 30 fps`
- log tempi separato
Risultati benchmark gia' analizzati:
- `benchmark_active_s = 762.13 s`
- durata video reale: `658.3 s`
- scarto residuo netto: circa `103.8 s` = `1.73 min`
- `active_fps = 25.97`
- `active_yolo_fps = 12.69`
Tempi medi in regime stabile:
- `mean_loop_ms = 36.90`
- `mean_read_ms = 6.10`
- `mean_yolo_ms = 31.20`
- `mean_track_ms = 0.18`
Conclusione benchmark:
- togliere la UI fa recuperare circa `5.5 min`
- la pipeline core e' vicina all'obiettivo ma non ancora allineata alla durata reale del video
- il residuo e' dovuto soprattutto a:
- inferenza YOLO sotto il target dei `15 fps`
- `cap.read()`
- struttura single-thread del loop
## Decisione architetturale emersa oggi
La prossima fase non deve essere "ottimizzare la GUI attuale", ma formalizzare una separazione netta dei ruoli.
### Ruoli da formalizzare
`Capture`
- acquisisce frame
- acquisisce posa drone nello stesso istante
- produce un pacchetto coerente `frame + pose + timestamp + frame_id`
`Vision / YOLO`
- riceve il frame gia' corredato di posa
- produce bbox, associazioni, offset, misure visive
- non decide il workflow
- non parla direttamente con WMS
`Navigation / Control`
- interpreta i risultati visivi
- decide comandi di scansione, centraggio, scatto, ritorno, ripresa
- e' il vero orchestratore della missione
`Drone I/O`
- invia i comandi al drone o al simulatore
- riceve stato / ack / posa aggiornata
- esegue lo scatto reale
`WMS Client`
- riceve payload finale pronto
- invia al WMS
- non decide il volo
## Punto concettuale gia' chiarito
Ogni frame che YOLO riceve deve avere gia' associata la posa del drone.
Quindi il thread di cattura non passa solo l'immagine, ma un record strutturato del tipo:
```text
CapturedFrame {
frame_id
timestamp
image
pose_x
pose_y
pose_z
yaw
pitch
roll
}
```
YOLO non deve rileggere la posizione "corrente" a posteriori.
La posa da usare per interpretare quel frame e' quella congelata al momento della cattura.
## Domande aperte per domani
1. Formalizzare le strutture dati tra thread:
- `CapturedFrame`
- `VisionResult`
- `DroneCommand`
- `CommandResult`
- `WmsPayload`
2. Disegnare la pipeline multi-thread minima:
- `capture_thread`
- `vision_thread`
- `navigation/control_thread`
- `wms_thread`
3. Decidere se il primo passo implementativo deve essere:
- solo `capture + vision` separati
- oppure `capture + vision + command pipeline`
4. Formalizzare bene il flusso "scatto etichetta":
- il controller decide
- il layer drone/scatto produce immagine e posa
- il payload finale va al WMS senza far diventare YOLO l'orchestratore del sistema
## Raccomandazione per il prossimo avvio
Domani, in una nuova chat, partire da qui:
1. leggere questo `handoff.md`
2. leggere gli ultimi aggiornamenti:
- [aggiornamento-2026-05-18-18-30.md](C:/devel/flywms/aggiornamento-2026-05-18-18-30.md)
- [aggiornamento-2026-05-18-19-14.md](C:/devel/flywms/aggiornamento-2026-05-18-19-14.md)
- [aggiornamento-2026-05-18-19-58.md](C:/devel/flywms/aggiornamento-2026-05-18-19-58.md)
3. cominciare scrivendo le specifiche formali di:
- responsabilita'
- messaggi tra thread
- ownership dei dati
- chi comanda chi
## Comandi utili
Server WMS:
```powershell
python flywms_wms_server.py
```
Demo navigazione con UI OpenCV:
```powershell
python flywms_navigation.py --wms-enabled
```
Benchmark headless:
```powershell
python flywms_navigation.py --benchmark-mode
```
GUI DearPyGUI:
```powershell
python flywms_navigation_gui.py
```

5
run_navigator.bat Normal file
View File

@@ -0,0 +1,5 @@
@echo off
title FlyWMS - Navigator
cd /d "%~dp0"
python flywms_navigation.py --video "testhd2_edit.mp4" --observer-enabled --wms-enabled

5
run_observer.bat Normal file
View File

@@ -0,0 +1,5 @@
@echo off
title FlyWMS - Observer
cd /d "%~dp0"
python flywms_navigation_observer.py --ui-backend dearpygui

5
run_wms_server.bat Normal file
View File

@@ -0,0 +1,5 @@
@echo off
title FlyWMS - WMS Server
cd /d "%~dp0"
python flywms_wms_server.py --ui-backend dearpygui

14
startdemo.bat Normal file
View File

@@ -0,0 +1,14 @@
@echo off
setlocal
set "ROOT=%~dp0"
set "PY_SCRIPT=%ROOT%startdemo.py"
if not exist "%PY_SCRIPT%" (
echo Script non trovato: "%PY_SCRIPT%"
exit /b 1
)
python "%PY_SCRIPT%"
set "EXIT_CODE=%ERRORLEVEL%"
exit /b %EXIT_CODE%

138
startdemo.py Normal file
View File

@@ -0,0 +1,138 @@
import os
import signal
import subprocess
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent
VIDEO = "testhd2_edit.mp4"
CHILDREN: list[subprocess.Popen] = []
STOPPING = False
def kill_tree(pid: int) -> None:
try:
subprocess.run(
["taskkill", "/PID", str(pid), "/T", "/F"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
except Exception:
pass
def pids_listening_on_port(port: int) -> list[int]:
try:
result = subprocess.run(
["netstat", "-ano", "-p", "tcp"],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=False,
)
except Exception:
return []
pids: set[int] = set()
suffix = f":{port}"
for raw_line in result.stdout.splitlines():
line = raw_line.strip()
if not line:
continue
parts = line.split()
if len(parts) < 5:
continue
proto, local_addr, _, state, pid_text = parts[:5]
if proto.upper() != "TCP":
continue
if state.upper() != "LISTENING":
continue
if not local_addr.endswith(suffix):
continue
try:
pids.add(int(pid_text))
except ValueError:
continue
return sorted(pids)
def preflight_cleanup() -> None:
stale_pids = pids_listening_on_port(8088)
for pid in stale_pids:
print(f"Pulizia preventiva: termino processo in ascolto su 8088 (PID {pid})", flush=True)
kill_tree(pid)
if stale_pids:
time.sleep(0.4)
def stop_all() -> None:
global STOPPING
if STOPPING:
return
STOPPING = True
print("\nArresto finestre demo...", flush=True)
for proc in CHILDREN:
if proc.poll() is None:
kill_tree(proc.pid)
def on_signal(signum, frame) -> None:
stop_all()
raise KeyboardInterrupt
def start_window(title: str, launcher_bat: str) -> subprocess.Popen:
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
startupinfo.wShowWindow = 6 # SW_MINIMIZE
return subprocess.Popen(
["cmd.exe", "/k", launcher_bat],
cwd=str(ROOT),
creationflags=subprocess.CREATE_NEW_CONSOLE,
startupinfo=startupinfo,
)
def main() -> int:
signal.signal(signal.SIGINT, on_signal)
if hasattr(signal, "SIGTERM"):
signal.signal(signal.SIGTERM, on_signal)
preflight_cleanup()
try:
print("Avvio WMS server...", flush=True)
CHILDREN.append(start_window("FlyWMS - WMS Server", "run_wms_server.bat"))
time.sleep(0.25)
print("Avvio observer...", flush=True)
CHILDREN.append(start_window("FlyWMS - Observer", "run_observer.bat"))
time.sleep(0.25)
print("Avvio navigator...", flush=True)
CHILDREN.append(start_window("FlyWMS - Navigator", "run_navigator.bat"))
print("\nDemo avviata.", flush=True)
for proc in CHILDREN:
print(f"- PID {proc.pid}", flush=True)
print("\nPremi Ctrl+C in questa finestra per chiudere tutto.\n", flush=True)
while True:
alive = [proc for proc in CHILDREN if proc.poll() is None]
if not alive:
print("Tutte le finestre demo sono terminate.", flush=True)
return 0
time.sleep(1.0)
except KeyboardInterrupt:
print("Interruzione rilevata.", flush=True)
return 130
finally:
stop_all()
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,261 @@
# FlyWMS Step 1 - Core/Observer Split
Data: 2026-05-29
## Scopo
Ridurre il tempo totale della demo fino a portarlo vicino alla durata reale del video, evitando che la parte visuale OpenCV rallenti il loop critico di acquisizione, inferenza e logica demo.
Lo Step 1 non cambia ancora la struttura single-process del core di calcolo, ma separa in modo netto:
- `core`: acquisizione, YOLO, tracking, macchina a stati, logging prestazioni
- `observer`: processo separato per preview e telemetria
L'obiettivo e' ottenere una demo visibile senza riportare nel loop principale il costo della vecchia UI OpenCV.
## Motivazione tecnica
I benchmark gia' disponibili dicono:
- video reale: `658.3 s`
- benchmark headless: `762.13 s`
- demo vecchia con UI integrata: molto piu' lenta
La UI OpenCV dentro al loop ha gia' dimostrato di pesare in modo determinante. La strada piu' pulita e' quindi:
1. mantenere il core il piu' simile possibile al benchmark
2. spostare la visualizzazione in un processo esterno
3. accettare che l'observer non mostri tutti i frame
## Perimetro di Step 1
Step 1 include:
- canale di telemetria `core -> observer`
- canale preview `core -> observer`
- observer separato e opzionale
- modalita' `latest update wins`
Step 1 non include ancora:
- multiprocessing tra capture e inference
- shared memory
- zero-copy
- nuovo frontend DearPyGUI
- protocollo distribuito multi-host
## Architettura proposta
### Processo core
Responsabilita':
- legge il video o la camera
- rispetta i limiti `preview_fps` e `yolo_fps`
- esegue YOLO
- aggiorna tracking e macchina a stati
- salva snapshot e gestisce WMS
- scrive `tempistiche.txt`
- pubblica telemetria e preview in modo non bloccante
Il core non deve mai aspettare l'observer.
### Processo observer
Responsabilita':
- ricevere dati dal core
- mantenere l'ultimo stato disponibile
- mostrare:
- preview principale
- eventuali preview secondarie
- comandi/stato
- fps/logica macchina a stati
L'observer puo' perdere aggiornamenti. Questo e' accettabile.
## Trasporto dati
Per Step 1 si usa un trasporto semplice su `localhost`, in modo da ridurre il rischio di complessita' prematura.
Scelta:
- socket TCP locale per preview e telemetria
Motivi:
- semplice da debuggare
- leggibile anche con strumenti esterni
- facile da riusare in seguito
- non richiede introdurre subito shared memory
## Formato messaggi
### Telemetria
Formato: JSON line-delimited o JSON framed su socket
Campi minimi previsti:
```json
{
"type": "telemetry",
"frame_id": 123,
"timestamp": 1710000000.123,
"state": "scan_level",
"command_lines": ["...", "..."],
"motion_text": "MOTO: stabile ...",
"snapshot_counter": 4,
"det_count": 2,
"label_count": 1,
"track_count": 2,
"active_track_count": 2,
"run_yolo": true,
"last_yolo_ms": 28.4,
"loop_fps": 25.8,
"yolo_fps": 12.6,
"video_fps": 30.0,
"preview_fps": 30.0,
"yolo_target_fps": 15.0
}
```
### Preview
Formato: messaggio con header JSON + payload JPEG.
Campi minimi header:
```json
{
"type": "preview",
"stream": "navigate",
"frame_id": 123,
"timestamp": 1710000000.123,
"width": 960,
"height": 540,
"encoding": "jpeg",
"jpeg_size": 45231
}
```
Il payload binario segue subito dopo l'header.
## Frequenze observer
Per non pesare sul core:
- telemetria: fino a `5-10 Hz`
- preview principale: `3-5 fps`
- preview snapshot/etichetta: solo su evento o ultima disponibile
Step 1 non ha l'obiettivo di mostrare una preview fluida. Ha l'obiettivo di restare credibile a livello demo senza penalizzare il core.
## Strategia "latest update wins"
Il core non accumula backlog per l'observer.
Regole:
- se l'observer e' lento, gli aggiornamenti vecchi possono essere scartati
- il core invia solo l'ultimo stato rilevante
- nessuna coda lunga per la preview
Questo vale in particolare per:
- preview `navigate`
- testo comandi
- metriche fps
## Gestione overlay
Step 1 adotta una soluzione intermedia:
- il core genera una preview gia' ridotta
- il core puo' continuare a disegnare l'overlay minimo necessario sulla preview inviata
- l'observer resta semplice e mostra il risultato
Questo non e' il modello finale ideale, ma riduce il lavoro di riscrittura nello Step 1.
Evoluzione futura possibile:
- inviare frame piu' bbox/stato
- disegnare overlay interamente nell'observer
## Modalita' di fallback
Il sistema deve funzionare anche senza observer.
Regole:
- se l'observer non e' avviato, il core continua a lavorare
- se il socket non e' disponibile, il core logga e prosegue
- se l'observer si scollega, il core non deve bloccarsi
Questo e' un requisito fondamentale dello Step 1.
## Parametri di configurazione da introdurre
Nuovi parametri suggeriti nel file INI:
- `observer_enabled = true/false`
- `observer_host = 127.0.0.1`
- `observer_port = 8765`
- `observer_preview_fps = 4.0`
- `observer_preview_width = 960`
- `observer_jpeg_quality = 75`
- `observer_telemetry_fps = 8.0`
## File previsti
### Nuovo file
- `flywms_navigation_observer.py`
Responsabilita':
- connettersi al core
- ricevere messaggi
- mantenere ultimo stato per stream
- mostrare finestre OpenCV lato observer
### Modifiche a file esistente
- `flywms_navigation.py`
- aggiunta configurazione observer
- serializzazione telemetria
- invio preview/telemetria non bloccante
- disattivazione progressiva della vecchia UI integrata
## Piano implementativo di Step 1
1. aggiungere parametri observer a config e argparse
2. creare un piccolo publisher non bloccante nel core
3. creare `flywms_navigation_observer.py`
4. pubblicare:
- preview `navigate`
- preview `snapshot`
- preview `etichetta`
- telemetria/comandi
5. rendere opzionale la vecchia UI integrata
6. testare:
- core senza observer
- core con observer
- benchmark con observer attivo ma leggero
## Criteri di successo
Step 1 e' considerato riuscito se:
1. il core continua a funzionare senza finestre locali
2. l'observer mostra una preview leggibile e lo stato della missione
3. l'observer non blocca il core se rallenta o si chiude
4. il tempo totale della demo si avvicina al benchmark headless, senza ricadere nella penalizzazione della vecchia UI
## Limiti noti
- Step 1 non risolve ancora il costo seriale di `cap.read()` e inferenza
- Step 1 non introduce ancora il multiprocessing tra capture e inference
- Step 1 non ottimizza ancora l'uso adattivo di YOLO
Questi temi restano demandati agli Step 2 e 3.

View File

@@ -0,0 +1,104 @@
# Step 2 - Disaccoppiamento Capture / Inference
## Obiettivo
Ridurre la serializzazione del loop principale separando:
- acquisizione video e campionamento metadati;
- inferenza, tracking e logica demo.
Lo scopo non e' introdurre ancora una pipeline completa multi-processo stile ROS, ma togliere dal percorso critico almeno la parte `cap.read()` e la preparazione del pacchetto frame.
## Vincolo architetturale
Il frame consumato dall'inferenza non puo' essere un'immagine grezza isolata.
Ogni frame deve essere rappresentato da un pacchetto logico di acquisizione che congela nello stesso istante:
- `frame_id`
- `timestamp`
- `video_time_sec`
- immagine
- posa / stato del drone campionati nel momento di acquisizione
Questo segue il modello gia' fissato nei documenti di architettura:
- `capture` non inventa la posa;
- `capture` la campiona dalla sorgente di stato disponibile;
- `inference` consuma un `CapturedFrame` gia' coerente.
Nel contesto della demo video attuale non esiste ancora una posa SLAM reale. Quindi il campo posa verra' popolato con una posa simulata minima, ma congelata all'acquisizione, utile a:
- mantenere il contratto architetturale corretto;
- evitare che in futuro frame e stato si disallineino;
- propagare i metadati fino agli snapshot.
## Struttura dati proposta
```text
CapturedFrame
- frame_id
- timestamp
- video_time_sec
- frame
- pose
```
Dove `pose` e' un dizionario o struttura equivalente con almeno:
- `mode`
- `frame_id`
- `video_time_sec`
- `scan_direction`
- `route_progress_ratio`
Il campo `pose` potra' essere sostituito piu' avanti da un provider reale SLAM / PX4 / odometria senza cambiare il contratto tra capture e inference.
## Strategia di esecuzione
### Capture worker
Un worker separato:
- legge da `VideoCapture`
- costruisce `CapturedFrame`
- pubblica solo l'ultimo frame disponibile
### Inference / control loop
Il loop principale:
- legge l'ultimo `CapturedFrame`
- esegue YOLO, tracking e logica demo
- usa sempre i metadati congelati nel pacchetto
## Regola di coda
La coda deve essere piccola:
- niente backlog lungo;
- meglio perdere frame che accumulare ritardo;
- politica `latest frame wins`.
## Impatto sul codice esistente
### Nuove entita'
- `CapturedFrame`
- `CaptureWorker`
- helper `sample_demo_pose(...)`
### Oggetti da aggiornare
- `CandidateSnapshot`
- `NavigationSnapshot`
- metadata snapshot JSON
- observer telemetry, se utile, con riferimento alla posa dell'ultimo frame
## Criteri di successo
1. il core continua a produrre lo stesso comportamento funzionale della demo;
2. inferenza e snapshot lavorano su frame con metadati coerenti;
3. `cap.read()` non sta piu' nel loop principale critico;
4. nessun backlog crescente;
5. nessun blocco se il capture worker arriva piu' veloce o piu' lento del consumer.

View File

@@ -0,0 +1,55 @@
# Step 3 - Inferenza adattiva
## Obiettivo
Ridurre il tempo totale della demo senza cambiare modello o logica di missione, variando la frequenza di YOLO in base al contesto invece di usare un solo valore fisso.
## Principio
L'inferenza non deve avere sempre lo stesso ritmo.
Ci sono tre situazioni principali:
1. **scansione tranquilla**
- nessuna track attiva
- nessun target interessante vicino alla zona utile
- YOLO puo' girare piu' lentamente
2. **tracciamento normale**
- una o piu' track attive
- target presenti ma non ancora in fase critica
- YOLO gira a frequenza intermedia
3. **fase critica / near snapshot**
- track in stato `candidate` o `centered`
- target vicino alla linea di snapshot
- YOLO gira a frequenza alta
## Regole iniziali
Versione conservativa:
- nessuna track attiva -> `idle_yolo_fps`
- track attive ma nessuna `candidate/centered` -> `tracking_yolo_fps`
- almeno una `candidate/centered` -> `critical_yolo_fps`
Queste regole usano lo stato gia' prodotto dal tracker e dal `NavigationController`, quindi non introducono nuova logica semantica invasiva.
## Vincoli
- la frequenza adattiva non deve scendere troppo, altrimenti si rischia di ritardare il riaggancio del target;
- la frequenza critica non deve superare in modo aggressivo la capacita' reale della macchina;
- il comportamento deve restare deterministico e leggibile nei log.
## Configurazione proposta
- `adaptive_yolo_enabled`
- `idle_yolo_fps`
- `tracking_yolo_fps`
- `critical_yolo_fps`
## Criterio di successo
1. ridurre la durata netta della demo;
2. non peggiorare visibilmente il comportamento di tracking/snapshot;
3. mantenere il codice semplice e facilmente spegnibile.

BIN
testhd2.mp4 LFS

Binary file not shown.