From f728524ee6eb8fa7ab47c2ad497b45b237ab5890 Mon Sep 17 00:00:00 2001 From: administrator Date: Tue, 19 May 2026 08:52:44 +0200 Subject: [PATCH] pipeline in linea single thread --- .gitignore | 1 + aggiornamento-2026-05-16-10-14.md | 99 ++++ aggiornamento-2026-05-16-10-37.md | 41 ++ aggiornamento-2026-05-16-10-47.md | 30 + aggiornamento-2026-05-16-10-49.md | 50 ++ aggiornamento-2026-05-16-11-04.md | 110 ++++ aggiornamento-2026-05-16-11-12.md | 132 +++++ aggiornamento-2026-05-16-11-29.md | 49 ++ aggiornamento-2026-05-16-12-04.md | 62 ++ aggiornamento-2026-05-16-12-08.md | 116 ++++ aggiornamento-2026-05-16-12-10.md | 43 ++ aggiornamento-2026-05-16-13-01.md | 188 ++++++ aggiornamento-2026-05-16-14-34.md | 26 + aggiornamento-2026-05-16-17-18.md | 75 +++ aggiornamento-2026-05-16-17-19.md | 84 +++ aggiornamento-2026-05-16-19-46.md | 69 +++ aggiornamento-2026-05-16-19-52.md | 25 + aggiornamento-2026-05-16-20-12.md | 94 +++ aggiornamento-2026-05-17-09-30.md | 29 + aggiornamento-2026-05-17-09-39.md | 53 ++ aggiornamento-2026-05-17-10-08.md | 18 + aggiornamento-2026-05-17-10-37.md | 20 + aggiornamento-2026-05-17-11-21.md | 82 +++ aggiornamento-2026-05-17-14-48.md | 46 ++ aggiornamento-2026-05-17-14-51.md | 52 ++ aggiornamento-2026-05-17-20-36.md | 75 +++ aggiornamento-2026-05-17-20-57.md | 166 ++++++ aggiornamento-2026-05-18-14-18.md | 196 +++++++ aggiornamento-2026-05-18-14-39.md | 115 ++++ aggiornamento-2026-05-18-14-58.md | 166 ++++++ aggiornamento-2026-05-18-15-28.md | 50 ++ aggiornamento-2026-05-18-15-42.md | 107 ++++ aggiornamento-2026-05-18-15-53.md | 82 +++ aggiornamento-2026-05-18-18-15.md | 43 ++ aggiornamento-2026-05-18-18-30.md | 67 +++ aggiornamento-2026-05-18-19-14.md | 58 ++ aggiornamento-2026-05-18-19-58.md | 97 ++++ flywms_navigation.ini | 228 +++++++- flywms_navigation.py | 909 +++++++++++++++++++++++++++--- flywms_navigation_gui.py | 24 +- flywms_paddleocr_worker.py | 340 +++++++++++ flywms_wms_server.py | 789 ++++++++++++++++++++++++++ handoff.md | 293 +++++++--- 43 files changed, 5245 insertions(+), 154 deletions(-) create mode 100644 aggiornamento-2026-05-16-10-14.md create mode 100644 aggiornamento-2026-05-16-10-37.md create mode 100644 aggiornamento-2026-05-16-10-47.md create mode 100644 aggiornamento-2026-05-16-10-49.md create mode 100644 aggiornamento-2026-05-16-11-04.md create mode 100644 aggiornamento-2026-05-16-11-12.md create mode 100644 aggiornamento-2026-05-16-11-29.md create mode 100644 aggiornamento-2026-05-16-12-04.md create mode 100644 aggiornamento-2026-05-16-12-08.md create mode 100644 aggiornamento-2026-05-16-12-10.md create mode 100644 aggiornamento-2026-05-16-13-01.md create mode 100644 aggiornamento-2026-05-16-14-34.md create mode 100644 aggiornamento-2026-05-16-17-18.md create mode 100644 aggiornamento-2026-05-16-17-19.md create mode 100644 aggiornamento-2026-05-16-19-46.md create mode 100644 aggiornamento-2026-05-16-19-52.md create mode 100644 aggiornamento-2026-05-16-20-12.md create mode 100644 aggiornamento-2026-05-17-09-30.md create mode 100644 aggiornamento-2026-05-17-09-39.md create mode 100644 aggiornamento-2026-05-17-10-08.md create mode 100644 aggiornamento-2026-05-17-10-37.md create mode 100644 aggiornamento-2026-05-17-11-21.md create mode 100644 aggiornamento-2026-05-17-14-48.md create mode 100644 aggiornamento-2026-05-17-14-51.md create mode 100644 aggiornamento-2026-05-17-20-36.md create mode 100644 aggiornamento-2026-05-17-20-57.md create mode 100644 aggiornamento-2026-05-18-14-18.md create mode 100644 aggiornamento-2026-05-18-14-39.md create mode 100644 aggiornamento-2026-05-18-14-58.md create mode 100644 aggiornamento-2026-05-18-15-28.md create mode 100644 aggiornamento-2026-05-18-15-42.md create mode 100644 aggiornamento-2026-05-18-15-53.md create mode 100644 aggiornamento-2026-05-18-18-15.md create mode 100644 aggiornamento-2026-05-18-18-30.md create mode 100644 aggiornamento-2026-05-18-19-14.md create mode 100644 aggiornamento-2026-05-18-19-58.md create mode 100644 flywms_paddleocr_worker.py create mode 100644 flywms_wms_server.py diff --git a/.gitignore b/.gitignore index f117592..27e51bb 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ dataset_yolo/labels/*_backup_before_remap_*/ runs/flywms_dataset_check/ runs/flywms_dataset_check_1epoch/ navigate_snapshots*/ +wms_received*/ diff --git a/aggiornamento-2026-05-16-10-14.md b/aggiornamento-2026-05-16-10-14.md new file mode 100644 index 0000000..a0e7840 --- /dev/null +++ b/aggiornamento-2026-05-16-10-14.md @@ -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. diff --git a/aggiornamento-2026-05-16-10-37.md b/aggiornamento-2026-05-16-10-37.md new file mode 100644 index 0000000..91a96c4 --- /dev/null +++ b/aggiornamento-2026-05-16-10-37.md @@ -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 +``` diff --git a/aggiornamento-2026-05-16-10-47.md b/aggiornamento-2026-05-16-10-47.md new file mode 100644 index 0000000..0bd8b5b --- /dev/null +++ b/aggiornamento-2026-05-16-10-47.md @@ -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. diff --git a/aggiornamento-2026-05-16-10-49.md b/aggiornamento-2026-05-16-10-49.md new file mode 100644 index 0000000..24015b7 --- /dev/null +++ b/aggiornamento-2026-05-16-10-49.md @@ -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. diff --git a/aggiornamento-2026-05-16-11-04.md b/aggiornamento-2026-05-16-11-04.md new file mode 100644 index 0000000..2f1ff0e --- /dev/null +++ b/aggiornamento-2026-05-16-11-04.md @@ -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. diff --git a/aggiornamento-2026-05-16-11-12.md b/aggiornamento-2026-05-16-11-12.md new file mode 100644 index 0000000..c77a939 --- /dev/null +++ b/aggiornamento-2026-05-16-11-12.md @@ -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. diff --git a/aggiornamento-2026-05-16-11-29.md b/aggiornamento-2026-05-16-11-29.md new file mode 100644 index 0000000..f1eea98 --- /dev/null +++ b/aggiornamento-2026-05-16-11-29.md @@ -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. diff --git a/aggiornamento-2026-05-16-12-04.md b/aggiornamento-2026-05-16-12-04.md new file mode 100644 index 0000000..9714784 --- /dev/null +++ b/aggiornamento-2026-05-16-12-04.md @@ -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. diff --git a/aggiornamento-2026-05-16-12-08.md b/aggiornamento-2026-05-16-12-08.md new file mode 100644 index 0000000..2fecfab --- /dev/null +++ b/aggiornamento-2026-05-16-12-08.md @@ -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`. diff --git a/aggiornamento-2026-05-16-12-10.md b/aggiornamento-2026-05-16-12-10.md new file mode 100644 index 0000000..9ce81fc --- /dev/null +++ b/aggiornamento-2026-05-16-12-10.md @@ -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) +``` diff --git a/aggiornamento-2026-05-16-13-01.md b/aggiornamento-2026-05-16-13-01.md new file mode 100644 index 0000000..a4a6382 --- /dev/null +++ b/aggiornamento-2026-05-16-13-01.md @@ -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. diff --git a/aggiornamento-2026-05-16-14-34.md b/aggiornamento-2026-05-16-14-34.md new file mode 100644 index 0000000..98996a9 --- /dev/null +++ b/aggiornamento-2026-05-16-14-34.md @@ -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`. diff --git a/aggiornamento-2026-05-16-17-18.md b/aggiornamento-2026-05-16-17-18.md new file mode 100644 index 0000000..12667ee --- /dev/null +++ b/aggiornamento-2026-05-16-17-18.md @@ -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" +``` diff --git a/aggiornamento-2026-05-16-17-19.md b/aggiornamento-2026-05-16-17-19.md new file mode 100644 index 0000000..4089142 --- /dev/null +++ b/aggiornamento-2026-05-16-17-19.md @@ -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. diff --git a/aggiornamento-2026-05-16-19-46.md b/aggiornamento-2026-05-16-19-46.md new file mode 100644 index 0000000..61ab3ff --- /dev/null +++ b/aggiornamento-2026-05-16-19-46.md @@ -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. + diff --git a/aggiornamento-2026-05-16-19-52.md b/aggiornamento-2026-05-16-19-52.md new file mode 100644 index 0000000..8c9f5c7 --- /dev/null +++ b/aggiornamento-2026-05-16-19-52.md @@ -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. + diff --git a/aggiornamento-2026-05-16-20-12.md b/aggiornamento-2026-05-16-20-12.md new file mode 100644 index 0000000..dd6c1e9 --- /dev/null +++ b/aggiornamento-2026-05-16-20-12.md @@ -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. + diff --git a/aggiornamento-2026-05-17-09-30.md b/aggiornamento-2026-05-17-09-30.md new file mode 100644 index 0000000..ee4ebb8 --- /dev/null +++ b/aggiornamento-2026-05-17-09-30.md @@ -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. + diff --git a/aggiornamento-2026-05-17-09-39.md b/aggiornamento-2026-05-17-09-39.md new file mode 100644 index 0000000..6c5e4f1 --- /dev/null +++ b/aggiornamento-2026-05-17-09-39.md @@ -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. + diff --git a/aggiornamento-2026-05-17-10-08.md b/aggiornamento-2026-05-17-10-08.md new file mode 100644 index 0000000..538e777 --- /dev/null +++ b/aggiornamento-2026-05-17-10-08.md @@ -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. + diff --git a/aggiornamento-2026-05-17-10-37.md b/aggiornamento-2026-05-17-10-37.md new file mode 100644 index 0000000..b41163f --- /dev/null +++ b/aggiornamento-2026-05-17-10-37.md @@ -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. + diff --git a/aggiornamento-2026-05-17-11-21.md b/aggiornamento-2026-05-17-11-21.md new file mode 100644 index 0000000..7961099 --- /dev/null +++ b/aggiornamento-2026-05-17-11-21.md @@ -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. + diff --git a/aggiornamento-2026-05-17-14-48.md b/aggiornamento-2026-05-17-14-48.md new file mode 100644 index 0000000..05fb1af --- /dev/null +++ b/aggiornamento-2026-05-17-14-48.md @@ -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. + diff --git a/aggiornamento-2026-05-17-14-51.md b/aggiornamento-2026-05-17-14-51.md new file mode 100644 index 0000000..8a22e1a --- /dev/null +++ b/aggiornamento-2026-05-17-14-51.md @@ -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. + diff --git a/aggiornamento-2026-05-17-20-36.md b/aggiornamento-2026-05-17-20-36.md new file mode 100644 index 0000000..35175f2 --- /dev/null +++ b/aggiornamento-2026-05-17-20-36.md @@ -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. + diff --git a/aggiornamento-2026-05-17-20-57.md b/aggiornamento-2026-05-17-20-57.md new file mode 100644 index 0000000..d59f86c --- /dev/null +++ b/aggiornamento-2026-05-17-20-57.md @@ -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. diff --git a/aggiornamento-2026-05-18-14-18.md b/aggiornamento-2026-05-18-14-18.md new file mode 100644 index 0000000..de64819 --- /dev/null +++ b/aggiornamento-2026-05-18-14-18.md @@ -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.jpgcodice_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. diff --git a/aggiornamento-2026-05-18-14-39.md b/aggiornamento-2026-05-18-14-39.md new file mode 100644 index 0000000..7057b6d --- /dev/null +++ b/aggiornamento-2026-05-18-14-39.md @@ -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. diff --git a/aggiornamento-2026-05-18-14-58.md b/aggiornamento-2026-05-18-14-58.md new file mode 100644 index 0000000..1e627ad --- /dev/null +++ b/aggiornamento-2026-05-18-14-58.md @@ -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. diff --git a/aggiornamento-2026-05-18-15-28.md b/aggiornamento-2026-05-18-15-28.md new file mode 100644 index 0000000..1d6f9bd --- /dev/null +++ b/aggiornamento-2026-05-18-15-28.md @@ -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. diff --git a/aggiornamento-2026-05-18-15-42.md b/aggiornamento-2026-05-18-15-42.md new file mode 100644 index 0000000..7338ac6 --- /dev/null +++ b/aggiornamento-2026-05-18-15-42.md @@ -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. diff --git a/aggiornamento-2026-05-18-15-53.md b/aggiornamento-2026-05-18-15-53.md new file mode 100644 index 0000000..4196084 --- /dev/null +++ b/aggiornamento-2026-05-18-15-53.md @@ -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. diff --git a/aggiornamento-2026-05-18-18-15.md b/aggiornamento-2026-05-18-18-15.md new file mode 100644 index 0000000..eb6a73f --- /dev/null +++ b/aggiornamento-2026-05-18-18-15.md @@ -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. diff --git a/aggiornamento-2026-05-18-18-30.md b/aggiornamento-2026-05-18-18-30.md new file mode 100644 index 0000000..6248d9d --- /dev/null +++ b/aggiornamento-2026-05-18-18-30.md @@ -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. diff --git a/aggiornamento-2026-05-18-19-14.md b/aggiornamento-2026-05-18-19-14.md new file mode 100644 index 0000000..f7aa54f --- /dev/null +++ b/aggiornamento-2026-05-18-19-14.md @@ -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. diff --git a/aggiornamento-2026-05-18-19-58.md b/aggiornamento-2026-05-18-19-58.md new file mode 100644 index 0000000..a83de6d --- /dev/null +++ b/aggiornamento-2026-05-18-19-58.md @@ -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 diff --git a/flywms_navigation.ini b/flywms_navigation.ini index a4f4a89..4a3139a 100644 --- a/flywms_navigation.ini +++ b/flywms_navigation.ini @@ -3,7 +3,7 @@ ; Ruolo: sorgente video usata per simulare la camera del drone. ; Se vuoto o "none", usa webcam 0. ; Default se non indicato: testhd.mp4 -video = testhd.mp4 +video = testhd2_edit.mp4 ; OBBLIGATORIO: si. ; Ruolo: modello Ultralytics/YOLO moderno usato per rilevare gaylord ed etichette. @@ -11,9 +11,9 @@ video = testhd.mp4 weights = C:\devel\flywms\runs\flywms_yolo11n_quick20\weights\best.pt ; OBBLIGATORIO: no. -; Ruolo: device usato da Ultralytics. Usa "cpu" ora; con GPU compatibile usare "0". +; Ruolo: device usato da Ultralytics. Usa "cpu" per forzare CPU; con GPU compatibile usare "0". ; Default se non indicato: cpu -ultralytics_device = cpu +ultralytics_device = 0 ; OBBLIGATORIO: no. ; Ruolo: dimensione input YOLO. 640 e' il valore usato nel training rapido. @@ -30,6 +30,12 @@ min_confidence = 0.25 ; 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 @@ -89,6 +95,11 @@ edge_margin_ratio = 0.0 ; 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 @@ -106,15 +117,168 @@ snapshot_window_frames = 1 snapshot_output_dir = navigate_snapshots ; OBBLIGATORIO: no. -; Ruolo: tempo simulato con cui il drone attende OCR remoto + verifica WMS. +; 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 = 2.0 +remote_ack_timeout_sec = 10.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 @@ -125,6 +289,17 @@ scan_direction = destra ; 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 @@ -137,6 +312,12 @@ realtime_playback = true ; 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. @@ -153,6 +334,21 @@ max_frames = 0 ; 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 @@ -177,3 +373,25 @@ flash_alpha = 0.70 ; 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, 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 diff --git a/flywms_navigation.py b/flywms_navigation.py index e88ffc5..f23da93 100644 --- a/flywms_navigation.py +++ b/flywms_navigation.py @@ -1,16 +1,62 @@ import argparse import configparser import json +import queue import sys +import threading import time +import uuid from dataclasses import dataclass, field from pathlib import Path +from urllib import request, error import cv2 import numpy as np 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) @dataclass(frozen=True) @@ -27,6 +73,8 @@ class CandidateSnapshot: timestamp: float frame: np.ndarray bbox: tuple[int, int, int, int] + label_bbox: tuple[int, int, int, int] + label_confidence: float score: float center_score: float size_score: float @@ -92,17 +140,40 @@ class NavigationSnapshot: simulated_position: str track_id: int bbox: tuple[int, int, int, int] + label_bbox: tuple[int, int, int, int] score: float debug_frame_path: str ocr_payload_path: str + label_payload_path: str + movement_vector_px: tuple[float, float] + + +@dataclass(frozen=True) +class WmsSnapshotJob: + request_id: str + snapshot: NavigationSnapshot + metadata: dict[str, object] + label_image_path: str + gaylord_image_path: str | None + + +@dataclass(frozen=True) +class WmsResult: + request_id: str + snapshot_id: int + status: str + message: str + response: dict[str, object] | None = None + error: str = "" class UltralyticsDetector: - def __init__(self, model_path: str, device: str): + def __init__(self, model_path: str, device: str, half: bool): from ultralytics import YOLO self.model = YOLO(model_path) self.device = device + self.half = bool(half) and str(device).strip().lower() != "cpu" names = self.model.names if isinstance(names, dict): self.classes = [str(names[i]) for i in sorted(names)] @@ -121,6 +192,7 @@ class UltralyticsDetector: imgsz=input_size, conf=min_confidence, device=self.device, + half=self.half, verbose=False, ) elapsed_ms = (time.perf_counter() - t0) * 1000.0 @@ -151,6 +223,133 @@ class UltralyticsDetector: return detections, elapsed_ms +class WmsAsyncClient: + def __init__(self, args): + self.args = args + self.enabled = bool(args.wms_enabled) + self.jobs: queue.Queue[WmsSnapshotJob | None] = queue.Queue(maxsize=args.wms_queue_max_size) + self.results: queue.Queue[WmsResult] = queue.Queue() + self._stop = threading.Event() + self._thread: threading.Thread | None = None + if self.enabled: + self._thread = threading.Thread(target=self._worker, name="flywms-wms-client", daemon=True) + self._thread.start() + + def close(self) -> None: + if not self.enabled: + return + self._stop.set() + try: + self.jobs.put_nowait(None) + except queue.Full: + pass + if self._thread is not None: + self._thread.join(timeout=2.0) + + def submit(self, snapshot: NavigationSnapshot) -> str | None: + if not self.enabled: + return None + request_id = str(uuid.uuid4()) + metadata = { + "request_id": request_id, + "client_id": self.args.wms_client_id, + "snapshot_id": snapshot.snapshot_id, + "frame_id": snapshot.frame_id, + "timestamp": snapshot.timestamp, + "simulated_position": snapshot.simulated_position, + "track_id": snapshot.track_id, + "gaylord_bbox": list(snapshot.bbox), + "label_bbox": list(snapshot.label_bbox), + "movement_vector_px": { + "dx": snapshot.movement_vector_px[0], + "dy": snapshot.movement_vector_px[1], + }, + } + job = WmsSnapshotJob( + request_id=request_id, + snapshot=snapshot, + metadata=metadata, + label_image_path=snapshot.label_payload_path, + gaylord_image_path=snapshot.debug_frame_path if self.args.wms_send_gaylord_debug else None, + ) + try: + self.jobs.put_nowait(job) + except queue.Full: + self.results.put(WmsResult( + request_id=request_id, + snapshot_id=snapshot.snapshot_id, + status="ERROR", + message="WMS_QUEUE_FULL", + error="queue full", + )) + return request_id + log(f"[WMS] job accodato request_id={request_id} snapshot={snapshot.snapshot_id}") + return request_id + + def poll_results(self) -> list[WmsResult]: + results: list[WmsResult] = [] + while True: + try: + results.append(self.results.get_nowait()) + except queue.Empty: + return results + + def wait_for_result(self, request_id: str | None, timeout_sec: float) -> WmsResult | None: + if not request_id: + return None + deadline = time.perf_counter() + max(0.0, timeout_sec) + deferred: list[WmsResult] = [] + try: + while time.perf_counter() <= deadline: + try: + result = self.results.get(timeout=0.05) + except queue.Empty: + continue + if result.request_id == request_id: + return result + deferred.append(result) + return None + finally: + for result in deferred: + self.results.put(result) + + def _worker(self) -> None: + while not self._stop.is_set(): + try: + job = self.jobs.get(timeout=0.2) + except queue.Empty: + continue + if job is None: + break + result = self._send_job(job) + self.results.put(result) + + def _send_job(self, job: WmsSnapshotJob) -> WmsResult: + try: + body, content_type = build_wms_multipart(job) + req = request.Request( + self.args.wms_server_url, + data=body, + headers={"Content-Type": content_type}, + method="POST", + ) + with request.urlopen(req, timeout=self.args.wms_timeout_sec) as response: + payload = json.loads(response.read().decode("utf-8")) + status = str(payload.get("status", "ERROR")) + message = str(payload.get("message", "")) + log(f"[WMS] risposta request_id={job.request_id} status={status} message={message}") + return WmsResult(job.request_id, job.snapshot.snapshot_id, status, message, payload) + except (OSError, error.URLError, TimeoutError, json.JSONDecodeError) as exc: + log(f"[WMS] errore request_id={job.request_id}: {exc}") + return WmsResult( + request_id=job.request_id, + snapshot_id=job.snapshot.snapshot_id, + status="ERROR", + message="WMS_SEND_ERROR", + error=str(exc), + ) + + class LightweightTracker: """Greedy bbox tracker: enough to explain and test navigation decisions.""" @@ -224,8 +423,10 @@ class NavigationController: self.last_command_lines: list[str] = [] self.last_snapshot_frame: np.ndarray | None = None self.last_ocr_payload_frame: np.ndarray | None = None + self.last_label_payload_frame: np.ndarray | None = None self.last_remote_result_text = "" self.motion_text = "MOTO: n/d" + self.label_movement_arrow: tuple[tuple[int, int], tuple[int, int]] | None = None def process_track( self, @@ -233,7 +434,9 @@ class NavigationController: frame: np.ndarray, frame_id: int, timestamp: float, + labels: list[Detection] | None = None, ) -> NavigationSnapshot | None: + labels = labels or [] frame_h, frame_w = frame.shape[:2] eligible, score_parts = self._is_snapshot_candidate(track, frame_w, frame_h) self._update_track_state(track, eligible, frame_w) @@ -242,11 +445,24 @@ class NavigationController: return None if eligible: + label = find_label_inside_bbox(track.bbox, labels) + if label is None: + track.last_candidate_reason = "label_missing" + self.last_command_text = f"ETICHETTA_NON_TROVATA track={track.id}" + self.last_command_lines = [ + self.last_command_text, + "MICRO_RICERCA_ETICHETTA", + "ATTENDI_FRAME_SUCCESSIVO", + ] + log(f"[NAV] ETICHETTA_NON_TROVATA track={track.id}: attendo frame successivo") + return None candidate = CandidateSnapshot( frame_id=frame_id, timestamp=timestamp, frame=frame.copy(), bbox=track.bbox, + label_bbox=label.bbox, + label_confidence=label.confidence, score=score_parts["score"], center_score=score_parts["center_score"], size_score=score_parts["size_score"], @@ -371,8 +587,10 @@ class NavigationController: simulated_position = f"gaylord {self.position_counter}" debug_name = f"snapshot_{self.snapshot_counter:04d}_track_{track.id:03d}_frame.jpg" payload_name = f"snapshot_{self.snapshot_counter:04d}_track_{track.id:03d}_ocr_payload.jpg" + label_payload_name = f"snapshot_{self.snapshot_counter:04d}_track_{track.id:03d}_label_payload.jpg" debug_path = self.output_dir / debug_name payload_path = self.output_dir / payload_name + label_payload_path = self.output_dir / label_payload_name cv2.imwrite(str(debug_path), best.frame) ocr_payload = crop_with_padding( best.frame, @@ -380,8 +598,26 @@ class NavigationController: self.args.ocr_payload_pad_ratio, ) cv2.imwrite(str(payload_path), ocr_payload) + label_payload = crop_with_padding( + best.frame, + best.label_bbox, + self.args.label_payload_pad_ratio, + ) + cv2.imwrite(str(label_payload_path), label_payload) self.last_snapshot_frame = best.frame.copy() self.last_ocr_payload_frame = ocr_payload.copy() + self.last_label_payload_frame = label_payload.copy() + + gaylord_center = bbox_center(best.bbox) + label_center = bbox_center(best.label_bbox) + movement_vector = ( + label_center[0] - gaylord_center[0], + label_center[1] - gaylord_center[1], + ) + self.label_movement_arrow = ( + (int(round(gaylord_center[0])), int(round(gaylord_center[1]))), + (int(round(label_center[0])), int(round(label_center[1]))), + ) snapshot = NavigationSnapshot( snapshot_id=self.snapshot_counter, @@ -390,9 +626,12 @@ class NavigationController: simulated_position=simulated_position, track_id=track.id, bbox=best.bbox, + label_bbox=best.label_bbox, score=best.score, debug_frame_path=str(debug_path), ocr_payload_path=str(payload_path), + label_payload_path=str(label_payload_path), + movement_vector_px=movement_vector, ) self._write_metadata(snapshot) self._print_commands(snapshot) @@ -428,6 +667,55 @@ class NavigationController: log("[CMD] SCATTA_FOTO_RETRY") return result + def apply_wms_result(self, result: WmsResult | None, snapshot: NavigationSnapshot) -> str: + if result is None: + self.last_remote_result_text = "WMS_TIMEOUT: nessuna risposta entro il timeout" + self.last_command_lines.extend([ + self.last_remote_result_text, + "MICRO_MOVE_CORRETTIVO", + "SCATTA_FOTO_RETRY", + ]) + log("[WMS] TIMEOUT nessuna risposta entro il timeout") + return "TIMEOUT" + + status = result.status.upper() + if status == "ACK": + message = result.message or "codice valido su WMS" + ocr_text = "" + if result.response: + ocr_text = str(result.response.get("ocr_text") or result.response.get("fake_ocr_text") or "") + self.last_remote_result_text = f"WMS_ACK: {message}" + resume_command = f"RIPARTI_{self.args.scan_direction.upper()}" + lines = [self.last_remote_result_text] + if ocr_text: + lines.append(f"OCR_CODICE {ocr_text}") + lines.append(resume_command) + self.last_command_lines.extend(lines) + log(f"[WMS] ACK request_id={result.request_id} snapshot={result.snapshot_id} {message}") + log(f"[CMD] {resume_command}") + return "ACK" + + if status == "NACK": + message = result.message or "codice non riconosciuto" + self.last_remote_result_text = f"WMS_NACK: {message}" + self.last_command_lines.extend([ + self.last_remote_result_text, + "MICRO_MOVE_CORRETTIVO", + "SCATTA_FOTO_RETRY", + ]) + log(f"[WMS] NACK request_id={result.request_id} snapshot={result.snapshot_id} {message}") + return "NACK" + + message = result.error or result.message or "errore WMS" + self.last_remote_result_text = f"WMS_ERROR: {message}" + self.last_command_lines.extend([ + self.last_remote_result_text, + "MICRO_MOVE_CORRETTIVO", + "SCATTA_FOTO_RETRY", + ]) + log(f"[WMS] ERROR request_id={result.request_id} snapshot={result.snapshot_id} {message}") + return "ERROR" + def set_motion_text(self, text: str) -> None: self.motion_text = text @@ -443,9 +731,15 @@ class NavigationController: }, "track_id": snapshot.track_id, "gaylord_bbox": list(snapshot.bbox), + "label_bbox": list(snapshot.label_bbox), "score": snapshot.score, + "movement_vector_px": { + "dx": snapshot.movement_vector_px[0], + "dy": snapshot.movement_vector_px[1], + }, "debug_frame_path": snapshot.debug_frame_path, "ocr_payload_path": snapshot.ocr_payload_path, + "label_payload_path": snapshot.label_payload_path, } with self.metadata_path.open("at", encoding="utf-8") as f: f.write(json.dumps(record, ensure_ascii=True) + "\n") @@ -456,23 +750,71 @@ class NavigationController: f"track={snapshot.track_id} frame={snapshot.frame_id} " f"pos={snapshot.simulated_position} score={snapshot.score:.2f}" ) + dx, dy = snapshot.movement_vector_px self.last_command_lines = [ self.last_command_text, "STOP", - f"SCATTA_FOTO {Path(snapshot.debug_frame_path).name}", - f"ESTRAI_BBOX_CENTRALE track={snapshot.track_id}", f"ASSOCIA_POSIZIONE {snapshot.simulated_position}", - f"INVIA_ROI_REMOTA {Path(snapshot.ocr_payload_path).name}", + f"ASSOCIA_ETICHETTA {snapshot.simulated_position} - etichetta {snapshot.snapshot_id}", + f"CENTRA_ETICHETTA dx={dx:+.0f}px dy={dy:+.0f}px durata={self.args.label_move_sec:.1f}s", + f"ATTENDI_STABILIZZAZIONE {self.args.label_stabilization_sec:.1f}s", + f"SCATTA_FOTO_ETICHETTA {Path(snapshot.label_payload_path).name}", + f"RITORNA_CENTRO_GAYLORD durata={self.args.label_return_sec:.1f}s", + f"SCATTA_FOTO_GAYLORD {Path(snapshot.debug_frame_path).name}", + f"INVIA_ROI_REMOTA {Path(snapshot.label_payload_path).name}", f"ATTENDI_ACK timeout={self.args.remote_ack_timeout_sec:.1f}s", ] log(f"[NAV] {self.last_command_text}") log("[CMD] STOP") - log(f"[CMD] SCATTA_FOTO {Path(snapshot.debug_frame_path).name}") - log(f"[CMD] ESTRAI_BBOX_CENTRALE track={snapshot.track_id}") log(f"[CMD] ASSOCIA_POSIZIONE {snapshot.simulated_position}") - log(f"[CMD] INVIA_ROI_REMOTA {Path(snapshot.ocr_payload_path).name}") + log(f"[CMD] ASSOCIA_ETICHETTA {snapshot.simulated_position} - etichetta {snapshot.snapshot_id}") + log(f"[CMD] CENTRA_ETICHETTA dx={dx:+.0f}px dy={dy:+.0f}px durata={self.args.label_move_sec:.1f}s") + log(f"[CMD] ATTENDI_STABILIZZAZIONE {self.args.label_stabilization_sec:.1f}s") + log(f"[CMD] SCATTA_FOTO_ETICHETTA {Path(snapshot.label_payload_path).name}") + log(f"[CMD] RITORNA_CENTRO_GAYLORD durata={self.args.label_return_sec:.1f}s") + log(f"[CMD] SCATTA_FOTO_GAYLORD {Path(snapshot.debug_frame_path).name}") + log(f"[CMD] INVIA_ROI_REMOTA {Path(snapshot.label_payload_path).name}") log(f"[CMD] ATTENDI_ACK timeout={self.args.remote_ack_timeout_sec:.1f}s") + def set_label_sequence_phase(self, snapshot: NavigationSnapshot, phase: str) -> None: + dx, dy = snapshot.movement_vector_px + base = [ + self.last_command_text, + "STOP", + f"ASSOCIA_POSIZIONE {snapshot.simulated_position}", + f"ASSOCIA_ETICHETTA {snapshot.simulated_position} - etichetta {snapshot.snapshot_id}", + ] + if phase == "move": + detail = [ + f"CENTRA_ETICHETTA dx={dx:+.0f}px dy={dy:+.0f}px durata={self.args.label_move_sec:.1f}s", + "FRECCIA_MOVIMENTO_GAYLORD_ETICHETTA", + ] + elif phase == "stabilize": + detail = [ + "DRONE_CENTRATO_SU_ETICHETTA", + f"ATTENDI_STABILIZZAZIONE {self.args.label_stabilization_sec:.1f}s", + ] + elif phase == "capture": + detail = [ + f"SCATTA_FOTO_ETICHETTA {Path(snapshot.label_payload_path).name}", + f"INVIA_ROI_REMOTA {Path(snapshot.label_payload_path).name}", + ] + elif phase == "wait_wms": + detail = [ + f"INVIA_ROI_REMOTA {Path(snapshot.label_payload_path).name}", + f"ATTENDI_WMS timeout={self.args.remote_ack_timeout_sec:.1f}s", + ] + elif phase == "return": + rdx = -dx + rdy = -dy + detail = [ + f"RITORNA_CENTRO_GAYLORD dx={rdx:+.0f}px dy={rdy:+.0f}px durata={self.args.label_return_sec:.1f}s", + "FRECCIA_RITORNO_ETICHETTA_GAYLORD", + ] + else: + detail = [] + self.last_command_lines = base + detail + def parse_args(): pre = argparse.ArgumentParser(add_help=False) @@ -488,9 +830,12 @@ def parse_args(): help="Modello Ultralytics .pt", ) ap.add_argument("--ultralytics-device", default=defaults["ultralytics_device"], help="Device Ultralytics: cpu oppure 0") + ap.add_argument("--yolo-half", action=argparse.BooleanOptionalAction, default=defaults["yolo_half"], + help="Usa FP16/half precision per YOLO quando il device e' GPU") ap.add_argument("--input-size", type=int, default=defaults["input_size"], help="Dimensione input YOLO") ap.add_argument("--min-confidence", type=float, default=defaults["min_confidence"], help="Confidenza minima") ap.add_argument("--target-class", default=defaults["target_class"], help="Classe da tracciare") + ap.add_argument("--label-class", default=defaults["label_class"], help="Classe etichetta da associare al gaylord") ap.add_argument("--max-track-missed", type=int, default=defaults["max_track_missed"], help="Frame persi prima di rimuovere una track") ap.add_argument("--min-match-score", type=float, default=defaults["min_match_score"], help="Soglia associazione detection-track") @@ -506,6 +851,8 @@ def parse_args(): ap.add_argument("--edge-margin-ratio", type=float, default=defaults["edge_margin_ratio"], help="Margine per considerare bbox tagliato") ap.add_argument("--ocr-payload-pad-ratio", type=float, default=defaults["ocr_payload_pad_ratio"], help="Padding intorno al bbox centrale inviato all'OCR remoto") + ap.add_argument("--label-payload-pad-ratio", type=float, default=defaults["label_payload_pad_ratio"], + help="Padding intorno al bbox etichetta inviato all'OCR remoto") ap.add_argument("--min-area-trend", type=float, default=defaults["min_area_trend"], help="Trend area minimo ammesso") ap.add_argument("--snapshot-window-frames", type=int, default=defaults["snapshot_window_frames"], help="Candidati da valutare prima dello snapshot") ap.add_argument("--snapshot-output-dir", default=defaults["snapshot_output_dir"], help="Directory snapshot e JSONL") @@ -513,10 +860,28 @@ def parse_args(): help="Tempo simulato di attesa OCR remoto/WMS") ap.add_argument("--remote-ack-mode", choices=["always-ack", "always-nack", "alternate"], default=defaults["remote_ack_mode"], help="Risposta remota simulata") + ap.add_argument("--wms-enabled", action="store_true", default=defaults["wms_enabled"], + help="Invia realmente snapshot al WMS demo") + ap.add_argument("--wms-server-url", default=defaults["wms_server_url"], help="Endpoint HTTP WMS") + ap.add_argument("--wms-client-id", default=defaults["wms_client_id"], help="Identificativo client WMS") + ap.add_argument("--wms-timeout-sec", type=float, default=defaults["wms_timeout_sec"], help="Timeout chiamata WMS") + ap.add_argument("--wms-queue-max-size", type=int, default=defaults["wms_queue_max_size"], help="Massimo job WMS in coda") + ap.add_argument("--wms-send-gaylord-debug", action="store_true", default=defaults["wms_send_gaylord_debug"], + help="Invia anche immagine/debug gaylord al server WMS") ap.add_argument("--scan-direction", choices=["destra", "sinistra"], default=defaults["scan_direction"], help="Direzione simulata di ripartenza dopo ACK") + ap.add_argument("--label-move-sec", type=float, default=defaults["label_move_sec"], + help="Durata simulata movimento verso etichetta") + ap.add_argument("--label-stabilization-sec", type=float, default=defaults["label_stabilization_sec"], + help="Attesa simulata stabilizzazione su etichetta") + ap.add_argument("--label-return-sec", type=float, default=defaults["label_return_sec"], + help="Durata simulata ritorno al centro gaylord") ap.add_argument("--preview-width", type=int, default=defaults["preview_width"], help="Larghezza preview") + ap.add_argument("--benchmark-mode", action=argparse.BooleanOptionalAction, default=defaults["benchmark_mode"], + help="Profilo benchmark: niente finestre, preview a 30 fps e log tempi dedicato") + ap.add_argument("--benchmark-preview-fps", type=float, default=defaults["benchmark_preview_fps"], + help="FPS target preview/cattura usati dal profilo benchmark") ap.add_argument("--realtime-playback", action="store_true", default=defaults["realtime_playback"], help="Rispetta FPS video") ap.add_argument("--preview-fps", type=float, default=defaults["preview_fps"], help="FPS massimo per lettura/preview realtime. 0 = FPS sorgente") @@ -524,6 +889,11 @@ def parse_args(): help="FPS massimo per inferenza YOLO. 0 = ogni frame di preview") ap.add_argument("--max-frames", type=int, default=defaults["max_frames"], help="Numero massimo frame; 0 = tutto") ap.add_argument("--stats-interval", type=float, default=defaults["stats_interval"], help="Intervallo log prestazioni") + ap.add_argument("--perf-log-path", default=defaults["perf_log_path"], help="File log tempi dettagliati") + ap.add_argument("--perf-log-flush-interval-sec", type=float, default=defaults["perf_log_flush_interval_sec"], + help="Intervallo flush file log tempi") + ap.add_argument("--perf-log-flush-lines", type=int, default=defaults["perf_log_flush_lines"], + help="Numero righe bufferizzate prima del flush del log tempi") ap.add_argument("--motion-report-interval", type=int, default=defaults["motion_report_interval"], help="Ogni quanti frame aggiornare la direzione moto stimata") ap.add_argument("--motion-min-pixels", type=float, default=defaults["motion_min_pixels"], @@ -531,6 +901,12 @@ def parse_args(): ap.add_argument("--debug-tracks", action="store_true", default=defaults["debug_tracks"], help="Logga stato e criteri delle track") ap.add_argument("--flash-alpha", type=float, default=defaults["flash_alpha"], help="Intensita' flash 0..1 al momento dello scatto") ap.add_argument("--no-display", action="store_true", default=defaults["no_display"], help="Disabilita finestra video") + ap.add_argument("--window-layout-enabled", action="store_true", default=defaults["window_layout_enabled"], + help="Posiziona e ridimensiona le finestre OpenCV") + ap.add_argument("--navigate-window", default=defaults["navigate_window"], help="Layout finestra navigate: x,y,w,h") + ap.add_argument("--commands-window", default=defaults["commands_window"], help="Layout finestra comandi: x,y,w,h") + ap.add_argument("--snapshot-window", default=defaults["snapshot_window"], help="Layout finestra snapshot: x,y,w,h") + ap.add_argument("--label-window", default=defaults["label_window"], help="Layout finestra etichetta: x,y,w,h") return ap.parse_args() @@ -539,9 +915,11 @@ def load_navigation_config(path_str: str) -> dict[str, object]: "video": "testhd.mp4", "weights": r"C:\devel\flywms\runs\flywms_yolo11n_quick20\weights\best.pt", "ultralytics_device": "cpu", + "yolo_half": True, "input_size": 640, "min_confidence": 0.25, "target_class": "gaylord", + "label_class": "etichetta", "max_track_missed": 8, "min_match_score": 0.25, "max_center_distance_ratio": 0.18, @@ -553,23 +931,43 @@ def load_navigation_config(path_str: str) -> dict[str, object]: "min_gaylord_area_ratio": 0.02, "edge_margin_ratio": 0.0, "ocr_payload_pad_ratio": 0.03, + "label_payload_pad_ratio": 0.20, "min_area_trend": -0.35, "snapshot_window_frames": 1, "snapshot_output_dir": "navigate_snapshots", "remote_ack_timeout_sec": 2.0, "remote_ack_mode": "always-ack", + "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, "scan_direction": "destra", + "label_move_sec": 3.0, + "label_stabilization_sec": 2.0, + "label_return_sec": 3.0, "preview_width": 1280, + "benchmark_mode": False, + "benchmark_preview_fps": 30.0, "realtime_playback": True, "preview_fps": 24.0, "yolo_fps": 15.0, "max_frames": 0, "stats_interval": 2.0, + "perf_log_path": "tempistiche.txt", + "perf_log_flush_interval_sec": 2.0, + "perf_log_flush_lines": 120, "motion_report_interval": 5, "motion_min_pixels": 1.5, "debug_tracks": True, "flash_alpha": 0.70, "no_display": False, + "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", } path = Path(path_str) @@ -600,6 +998,47 @@ def log(msg: str) -> None: print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True) +def format_fps_value(value: float | int | None) -> str: + if value is None: + return "n/d" + try: + numeric = float(value) + except (TypeError, ValueError): + return "n/d" + if numeric <= 0: + return "n/d" + return f"{numeric:.1f}" + + +class PerfLogWriter: + def __init__(self, path_str: str, flush_interval_sec: float, flush_lines: int): + self.path = Path(path_str) + self.path.parent.mkdir(parents=True, exist_ok=True) + self.file = self.path.open("a", encoding="utf-8") + self.flush_interval_sec = max(0.2, float(flush_interval_sec)) + self.flush_lines = max(1, int(flush_lines)) + self.buffered_lines = 0 + self.last_flush = time.perf_counter() + + def write(self, line: str) -> None: + self.file.write(line.rstrip("\n") + "\n") + self.buffered_lines += 1 + self.maybe_flush() + + def maybe_flush(self) -> None: + now = time.perf_counter() + if self.buffered_lines >= self.flush_lines or (now - self.last_flush) >= self.flush_interval_sec: + self.file.flush() + self.buffered_lines = 0 + self.last_flush = now + + def close(self) -> None: + try: + self.file.flush() + finally: + self.file.close() + + def require_file(path_str: str, description: str) -> Path: path = Path(path_str) if not path.exists(): @@ -625,6 +1064,74 @@ def open_capture(video_arg: str | None): return cv2.VideoCapture(video_arg), str(video_arg) +def build_wms_multipart(job: WmsSnapshotJob) -> tuple[bytes, str]: + boundary = f"flywms-{uuid.uuid4().hex}" + chunks: list[bytes] = [] + + def add_field(name: str, value: str, content_type: str = "text/plain") -> None: + chunks.extend([ + f"--{boundary}\r\n".encode("ascii"), + f'Content-Disposition: form-data; name="{name}"\r\n'.encode("ascii"), + f"Content-Type: {content_type}\r\n\r\n".encode("ascii"), + value.encode("utf-8"), + b"\r\n", + ]) + + def add_file(name: str, path_str: str) -> None: + path = Path(path_str) + chunks.extend([ + f"--{boundary}\r\n".encode("ascii"), + ( + f'Content-Disposition: form-data; name="{name}"; ' + f'filename="{path.name}"\r\n' + ).encode("utf-8"), + b"Content-Type: image/jpeg\r\n\r\n", + path.read_bytes(), + b"\r\n", + ]) + + add_field("metadata", json.dumps(job.metadata, ensure_ascii=True), "application/json") + add_file("label_image", job.label_image_path) + if job.gaylord_image_path is not None: + add_file("gaylord_image", job.gaylord_image_path) + chunks.append(f"--{boundary}--\r\n".encode("ascii")) + return b"".join(chunks), f"multipart/form-data; boundary={boundary}" + + +def parse_window_rect(value: str | None) -> tuple[int, int, int, int] | None: + if value is None: + return None + parts = [part.strip() for part in str(value).split(",")] + if len(parts) != 4: + return None + try: + x, y, w, h = [int(part) for part in parts] + except ValueError: + return None + if w <= 0 or h <= 0: + return None + return x, y, w, h + + +def apply_window_layout(args) -> None: + if not args.window_layout_enabled: + return + layout = { + "flywms navigate": args.navigate_window, + "flywms comandi": args.commands_window, + "flywms snapshot": args.snapshot_window, + "flywms etichetta": args.label_window, + } + for name, value in layout.items(): + rect = parse_window_rect(value) + if rect is None: + log(f"Layout finestra ignorato per {name}: {value}") + continue + x, y, w, h = rect + cv2.resizeWindow(name, w, h) + cv2.moveWindow(name, x, y) + + def clip_box(x1: int, y1: int, x2: int, y2: int, w: int, h: int) -> tuple[int, int, int, int]: x1 = max(0, min(x1, w - 1)) y1 = max(0, min(y1, h - 1)) @@ -664,6 +1171,35 @@ def bbox_center(bbox: tuple[int, int, int, int]) -> tuple[float, float]: return (x1 + x2) * 0.5, (y1 + y2) * 0.5 +def bbox_contains( + outer: tuple[int, int, int, int], + inner: tuple[int, int, int, int], +) -> bool: + ox1, oy1, ox2, oy2 = outer + ix1, iy1, ix2, iy2 = inner + return ox1 <= ix1 and oy1 <= iy1 and ix2 <= ox2 and iy2 <= oy2 + + +def find_label_inside_bbox( + gaylord_bbox: tuple[int, int, int, int], + labels: list[Detection], +) -> Detection | None: + contained = [label for label in labels if bbox_contains(gaylord_bbox, label.bbox)] + if not contained: + return None + if len(contained) == 1: + return contained[0] + # Multiple contained labels should not happen; choose a deterministic fallback. + gx, gy = bbox_center(gaylord_bbox) + return min( + contained, + key=lambda label: ( + np.hypot(bbox_center(label.bbox)[0] - gx, bbox_center(label.bbox)[1] - gy), + -label.confidence, + ), + ) + + def bbox_iou(a: tuple[int, int, int, int], b: tuple[int, int, int, int]) -> float: ax1, ay1, ax2, ay2 = a bx1, by1, bx2, by2 = b @@ -696,16 +1232,17 @@ def resize_preview(frame: np.ndarray, max_width: int) -> np.ndarray: if max_width <= 0 or w <= max_width: return frame scale = max_width / float(w) - return cv2.resize(frame, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_LINEAR) + return cuda_resize(frame, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_LINEAR) def draw_navigation_debug( frame: np.ndarray, tracks: list[Track], args, - last_command_text: str, - fps_text: str, + labels: list[Detection] | None = None, + movement_arrow: tuple[tuple[int, int], tuple[int, int]] | None = None, ) -> np.ndarray: + labels = labels or [] display = frame.copy() h, w = display.shape[:2] center_x = int(w * 0.5) @@ -726,30 +1263,27 @@ def draw_navigation_debug( cx, cy = bbox_center(track.bbox) cv2.circle(display, (int(cx), int(cy)), 12, color, -1) cv2.circle(display, (int(cx), int(cy)), 18, (0, 0, 0), 3) - text = ( - f"id={track.id} {track.state} conf={track.confidence:.2f} " - f"hits={track.hits} trend={track.area_trend():+.2f}" - ) - cv2.putText( - display, - text, - (x1, max(24, y1 - 8)), - cv2.FONT_HERSHEY_SIMPLEX, - 0.78, - color, - 3, - cv2.LINE_AA, - ) - cv2.putText(display, fps_text, (20, 34), cv2.FONT_HERSHEY_SIMPLEX, 0.85, (0, 0, 255), 2) - if last_command_text: - cv2.putText(display, last_command_text, (20, 68), cv2.FONT_HERSHEY_SIMPLEX, 0.75, (0, 255, 255), 2) + for label in labels: + x1, y1, x2, y2 = label.bbox + cv2.rectangle(display, (x1, y1), (x2, y2), (0, 170, 255), 4) + lx, ly = bbox_center(label.bbox) + cv2.circle(display, (int(lx), int(ly)), 8, (0, 170, 255), -1) + + if movement_arrow is not None: + start, end = movement_arrow + cv2.arrowedLine(display, start, end, (0, 0, 0), 12, cv2.LINE_AA, tipLength=0.12) + cv2.arrowedLine(display, end, start, (0, 0, 0), 12, cv2.LINE_AA, tipLength=0.12) + return resize_preview(display, args.preview_width) def draw_commands_window(command_lines: list[str], motion_text: str) -> np.ndarray: lines = command_lines if command_lines else ["Nessun comando generato"] - canvas_h = max(340, 84 + len(lines[:10]) * 34) + visible_lines = lines[:14] + arrow_mode = command_arrow_mode(lines) + arrow_h = 210 if arrow_mode else 0 + canvas_h = max(340, 84 + len(visible_lines) * 34 + arrow_h) canvas = np.full((canvas_h, 980, 3), 245, dtype=np.uint8) cv2.putText( canvas, @@ -772,7 +1306,7 @@ def draw_commands_window(command_lines: list[str], motion_text: str) -> np.ndarr cv2.LINE_AA, ) y = 122 - for idx, line in enumerate(lines[:10]): + for idx, line in enumerate(visible_lines): color = (0, 0, 180) if idx == 0 else (0, 90, 0) cv2.putText( canvas, @@ -785,9 +1319,61 @@ def draw_commands_window(command_lines: list[str], motion_text: str) -> np.ndarr cv2.LINE_AA, ) y += 36 + if arrow_mode: + draw_command_arrow(canvas, arrow_mode, y + 16) return canvas +def command_arrow_mode(lines: list[str]) -> tuple[str, float, float] | None: + for line in lines: + if line.startswith("CENTRA_ETICHETTA"): + return "to_label", *parse_command_delta(line) + if line.startswith("RITORNA_CENTRO_GAYLORD"): + dx, dy = parse_command_delta(line) + return "to_gaylord", -dx, -dy + return None + + +def parse_command_delta(line: str) -> tuple[float, float]: + dx = 0.0 + dy = 0.0 + for part in line.split(): + if part.startswith("dx="): + dx = float(part[3:].replace("px", "")) + elif part.startswith("dy="): + dy = float(part[3:].replace("px", "")) + return dx, dy + + +def draw_command_arrow(canvas: np.ndarray, arrow_info: tuple[str, float, float], y0: int) -> None: + mode, dx, dy = arrow_info + h, w = canvas.shape[:2] + y0 = min(max(y0, 110), h - 160) + center = (w // 2 - 120, y0 + 130) + northeast = (w // 2 + 150, y0 + 34) + if mode == "to_label": + start, end = center, northeast + title = "MOVIMENTO VERSO ETICHETTA" + start_label = "CENTRO GAYLORD" + end_label = "ETICHETTA" + else: + start, end = northeast, center + title = "RITORNO AL CENTRO GAYLORD" + start_label = "ETICHETTA" + end_label = "CENTRO GAYLORD" + + cv2.rectangle(canvas, (24, y0), (w - 24, min(h - 18, y0 + 170)), (232, 232, 232), -1) + cv2.rectangle(canvas, (24, y0), (w - 24, min(h - 18, y0 + 170)), (120, 120, 120), 2) + cv2.putText(canvas, title, (44, y0 + 34), cv2.FONT_HERSHEY_SIMPLEX, 0.86, (0, 0, 0), 2, cv2.LINE_AA) + cv2.putText(canvas, f"DX ORIZZONTALE: {dx:+.0f} px", (44, y0 + 72), cv2.FONT_HERSHEY_SIMPLEX, 0.86, (0, 0, 180), 2, cv2.LINE_AA) + cv2.putText(canvas, f"DY VERTICALE: {dy:+.0f} px", (44, y0 + 108), cv2.FONT_HERSHEY_SIMPLEX, 0.86, (0, 0, 180), 2, cv2.LINE_AA) + cv2.circle(canvas, center, 16, (70, 70, 70), -1) + cv2.circle(canvas, northeast, 16, (0, 120, 255), -1) + cv2.arrowedLine(canvas, start, end, (0, 0, 0), 12, cv2.LINE_AA, tipLength=0.22) + cv2.putText(canvas, start_label, (start[0] - 115, start[1] + 42), cv2.FONT_HERSHEY_SIMPLEX, 0.62, (0, 0, 0), 2, cv2.LINE_AA) + cv2.putText(canvas, end_label, (end[0] - 78, max(24, end[1] - 24)), cv2.FONT_HERSHEY_SIMPLEX, 0.62, (0, 0, 0), 2, cv2.LINE_AA) + + def apply_flash(frame: np.ndarray, alpha: float) -> np.ndarray: flash = np.full_like(frame, 255) alpha = min(max(alpha, 0.0), 1.0) @@ -835,10 +1421,24 @@ def state_color(state: str) -> tuple[int, int, int]: def main() -> int: args = parse_args() + if args.benchmark_mode: + args.no_display = True + args.window_layout_enabled = False + args.realtime_playback = True + args.preview_fps = args.benchmark_preview_fps + if args.perf_log_path == "tempistiche.txt": + args.perf_log_path = "tempistiche-benchmark.txt" + log( + f"Profilo benchmark attivo: no_display=true " + f"preview_fps={format_fps_value(args.preview_fps)} " + f"log={args.perf_log_path}" + ) require_file(args.weights, "modello Ultralytics") - detector = UltralyticsDetector(args.weights, args.ultralytics_device) + detector = UltralyticsDetector(args.weights, args.ultralytics_device, args.yolo_half) log(f"Classi modello: {detector.classes}") + log(f"OpenCV CUDA: {'attivo' if OPENCV_CUDA_AVAILABLE else 'non disponibile'}") + log(f"YOLO half precision: {'attiva' if detector.half else 'disattiva'}") log("Nota tracker: questa versione usa tracking geometrico interno; ByteTrack/BoT-SORT restano candidati per confronto successivo.") cap, source_name = open_capture(args.video) @@ -852,17 +1452,37 @@ def main() -> int: cap.set(cv2.CAP_PROP_FPS, float(args.preview_fps)) frame_delay = 1.0 / preview_fps if args.realtime_playback and preview_fps and preview_fps > 1 else 0.0 yolo_interval = 1.0 / args.yolo_fps if args.yolo_fps and args.yolo_fps > 0 else 0.0 + log( + f"FPS sorgente={format_fps_value(video_fps)} " + f"preview_target={format_fps_value(preview_fps)} " + f"yolo_target={format_fps_value(args.yolo_fps)}" + ) + perf_writer = PerfLogWriter( + args.perf_log_path, + args.perf_log_flush_interval_sec, + args.perf_log_flush_lines, + ) + log(f"Log tempistiche: {perf_writer.path.resolve()}") + perf_writer.write( + "ts\tframe_id\trun_yolo\tread_ms\tyolo_ms\ttrack_ms\tdraw_ms\tui_ms\t" + "snapshot_pause_ms\twms_wait_ms\tloop_ms\tloop_fps\tyolo_real_fps\t" + "src_fps\tpreview_target\tyolo_target\tdet\tlabels\ttracks\tactive\t" + "snapshots\tcommand" + ) tracker = LightweightTracker( max_missed=args.max_track_missed, min_match_score=args.min_match_score, max_center_distance_ratio=args.max_center_distance_ratio, ) navigator = NavigationController(args) + wms_client = WmsAsyncClient(args) if not args.no_display: cv2.namedWindow("flywms navigate", cv2.WINDOW_NORMAL) cv2.namedWindow("flywms snapshot", cv2.WINDOW_NORMAL) + cv2.namedWindow("flywms etichetta", cv2.WINDOW_NORMAL) cv2.namedWindow("flywms comandi", cv2.WINDOW_NORMAL) + apply_window_layout(args) frame_id = 0 start_time = time.perf_counter() @@ -873,10 +1493,18 @@ def main() -> int: next_yolo_time = start_time last_yolo_ms = 0.0 gaylords: list[Detection] = [] + labels: list[Detection] = [] tracks: list[Track] = [] try: while True: + frame_loop_start = time.perf_counter() + read_ms = 0.0 + track_ms = 0.0 + draw_ms = 0.0 + ui_ms = 0.0 + snapshot_pause_ms = 0.0 + wms_wait_ms = 0.0 if frame_delay > 0: now = time.perf_counter() sleep_for = frame_delay - (now - last_loop_end) @@ -884,7 +1512,9 @@ def main() -> int: time.sleep(sleep_for) last_loop_end = time.perf_counter() + read_t0 = time.perf_counter() ok, frame = cap.read() + read_ms = (time.perf_counter() - read_t0) * 1000.0 if not ok: log("Fine stream") break @@ -905,7 +1535,12 @@ def main() -> int: det for det in detections if det.class_name.strip().lower() == args.target_class.strip().lower() ] + labels = [ + det for det in detections + if det.class_name.strip().lower() == args.label_class.strip().lower() + ] + track_t0 = time.perf_counter() tracks = tracker.update(gaylords, frame_id, frame.shape[1]) if args.motion_report_interval > 0 and yolo_cycles % args.motion_report_interval == 0: navigator.set_motion_text( @@ -913,14 +1548,33 @@ def main() -> int: ) for track in tracks: if track.missed == 0: - snapshot = navigator.process_track(track, frame, frame_id, timestamp) + snapshot = navigator.process_track(track, frame, frame_id, timestamp, labels) if snapshot is not None: new_snapshots.append(snapshot) + track_ms = (time.perf_counter() - track_t0) * 1000.0 if args.no_display and new_snapshots: - if args.remote_ack_timeout_sec > 0: - time.sleep(args.remote_ack_timeout_sec) + sequence_sec = ( + args.label_move_sec + + args.label_stabilization_sec + + args.label_return_sec + ) + if sequence_sec > 0: + pause_t0 = time.perf_counter() + time.sleep(sequence_sec) + snapshot_pause_ms += (time.perf_counter() - pause_t0) * 1000.0 for snapshot in new_snapshots: - navigator.simulate_remote_response(snapshot) + if args.wms_enabled: + request_id = wms_client.submit(snapshot) + wait_t0 = time.perf_counter() + result = wms_client.wait_for_result(request_id, args.remote_ack_timeout_sec) + wms_wait_ms += (time.perf_counter() - wait_t0) * 1000.0 + navigator.apply_wms_result(result, snapshot) + else: + if args.remote_ack_timeout_sec > 0: + wait_t0 = time.perf_counter() + time.sleep(args.remote_ack_timeout_sec) + wms_wait_ms += (time.perf_counter() - wait_t0) * 1000.0 + navigator.simulate_remote_response(snapshot) now = time.perf_counter() if now - last_stats >= args.stats_interval: @@ -929,7 +1583,8 @@ def main() -> int: active = sum(1 for t in tracks if t.missed == 0) log( f"fps={frame_id / elapsed:.1f} yolo_fps={yolo_cycles / elapsed:.1f} " - f"avg_yolo={avg_yolo:.1f}ms det={len(gaylords)} tracks={len(tracks)} active={active} " + f"avg_yolo={avg_yolo:.1f}ms det={len(gaylords)} labels={len(labels)} " + f"tracks={len(tracks)} active={active} " f"snapshots={navigator.snapshot_counter} {navigator.motion_text}" ) if args.debug_tracks: @@ -945,59 +1600,183 @@ def main() -> int: last_stats = now if not args.no_display: - elapsed = max(time.perf_counter() - start_time, 0.001) - fps_text = ( - f"frame={frame_id} fps={frame_id / elapsed:.1f} " - f"yolo_fps={yolo_cycles / elapsed:.1f} yolo={last_yolo_ms:.0f}ms " - f"det={len(gaylords)} tracks={len(tracks)} snap={navigator.snapshot_counter}" - ) + draw_t0 = time.perf_counter() display = draw_navigation_debug( frame, tracks, args, - navigator.last_command_text, - fps_text, + labels, + navigator.label_movement_arrow, ) + draw_ms += (time.perf_counter() - draw_t0) * 1000.0 + ui_t0 = time.perf_counter() cv2.imshow("flywms navigate", display) if navigator.last_ocr_payload_frame is not None: snapshot_display = resize_preview(navigator.last_ocr_payload_frame, args.preview_width) cv2.imshow("flywms snapshot", snapshot_display) + if navigator.last_label_payload_frame is not None and not new_snapshots: + label_display = resize_preview(navigator.last_label_payload_frame, args.preview_width) + cv2.imshow("flywms etichetta", label_display) cv2.imshow( "flywms comandi", draw_commands_window(navigator.last_command_lines, navigator.motion_text), ) + ui_ms += (time.perf_counter() - ui_t0) * 1000.0 if new_snapshots: - flash_display = apply_flash(display, args.flash_alpha) - cv2.imshow("flywms navigate", flash_display) - if navigator.last_ocr_payload_frame is not None: - flash_snapshot = apply_flash( - resize_preview(navigator.last_ocr_payload_frame, args.preview_width), - args.flash_alpha, - ) - cv2.imshow("flywms snapshot", flash_snapshot) - cv2.imshow( - "flywms comandi", - draw_commands_window(navigator.last_command_lines, navigator.motion_text), - ) - pause_ms = max(1, int(args.remote_ack_timeout_sec * 1000)) - key = cv2.waitKey(pause_ms) & 0xFF - if key in (27, ord("q")): - log("Interrotto da tastiera") - break + stop_requested = False for snapshot in new_snapshots: - navigator.simulate_remote_response(snapshot) - cv2.imshow( - "flywms comandi", - draw_commands_window(navigator.last_command_lines, navigator.motion_text), - ) + navigator.set_label_sequence_phase(snapshot, "move") + ui_t0 = time.perf_counter() + cv2.imshow("flywms navigate", display) + cv2.imshow( + "flywms comandi", + draw_commands_window(navigator.last_command_lines, navigator.motion_text), + ) + ui_ms += (time.perf_counter() - ui_t0) * 1000.0 + wait_t0 = time.perf_counter() + key = cv2.waitKey(max(1, int(args.label_move_sec * 1000))) & 0xFF + snapshot_pause_ms += (time.perf_counter() - wait_t0) * 1000.0 + if key in (27, ord("q")): + log("Interrotto da tastiera") + stop_requested = True + break + navigator.set_label_sequence_phase(snapshot, "stabilize") + ui_t0 = time.perf_counter() + cv2.imshow("flywms navigate", display) + cv2.imshow( + "flywms comandi", + draw_commands_window(navigator.last_command_lines, navigator.motion_text), + ) + ui_ms += (time.perf_counter() - ui_t0) * 1000.0 + wait_t0 = time.perf_counter() + key = cv2.waitKey(max(1, int(args.label_stabilization_sec * 1000))) & 0xFF + snapshot_pause_ms += (time.perf_counter() - wait_t0) * 1000.0 + if key in (27, ord("q")): + log("Interrotto da tastiera") + stop_requested = True + break + + navigator.set_label_sequence_phase(snapshot, "capture") + flash_display = apply_flash(display, args.flash_alpha) + ui_t0 = time.perf_counter() + cv2.imshow("flywms navigate", flash_display) + if navigator.last_label_payload_frame is not None: + flash_label = apply_flash( + resize_preview(navigator.last_label_payload_frame, args.preview_width), + args.flash_alpha, + ) + cv2.imshow("flywms etichetta", flash_label) + cv2.imshow( + "flywms comandi", + draw_commands_window(navigator.last_command_lines, navigator.motion_text), + ) + ui_ms += (time.perf_counter() - ui_t0) * 1000.0 + wait_t0 = time.perf_counter() + key = cv2.waitKey(500) & 0xFF + snapshot_pause_ms += (time.perf_counter() - wait_t0) * 1000.0 + if key in (27, ord("q")): + log("Interrotto da tastiera") + stop_requested = True + break + + request_id = wms_client.submit(snapshot) if args.wms_enabled else None + + navigator.set_label_sequence_phase(snapshot, "return") + ui_t0 = time.perf_counter() + cv2.imshow("flywms navigate", display) + if navigator.last_label_payload_frame is not None: + cv2.imshow( + "flywms etichetta", + resize_preview(navigator.last_label_payload_frame, args.preview_width), + ) + cv2.imshow( + "flywms comandi", + draw_commands_window(navigator.last_command_lines, navigator.motion_text), + ) + ui_ms += (time.perf_counter() - ui_t0) * 1000.0 + wait_t0 = time.perf_counter() + key = cv2.waitKey(max(1, int(args.label_return_sec * 1000))) & 0xFF + snapshot_pause_ms += (time.perf_counter() - wait_t0) * 1000.0 + if key in (27, ord("q")): + log("Interrotto da tastiera") + stop_requested = True + break + + navigator.set_label_sequence_phase(snapshot, "wait_wms") + ui_t0 = time.perf_counter() + cv2.imshow("flywms navigate", display) + if navigator.last_label_payload_frame is not None: + cv2.imshow( + "flywms etichetta", + resize_preview(navigator.last_label_payload_frame, args.preview_width), + ) + cv2.imshow( + "flywms comandi", + draw_commands_window(navigator.last_command_lines, navigator.motion_text), + ) + ui_ms += (time.perf_counter() - ui_t0) * 1000.0 + wait_deadline = time.perf_counter() + max(0.0, args.remote_ack_timeout_sec) + wms_result: WmsResult | None = None + if args.wms_enabled: + wait_t0 = time.perf_counter() + while time.perf_counter() <= wait_deadline: + wms_result = wms_client.wait_for_result(request_id, 0.05) + key = cv2.waitKey(1) & 0xFF + if key in (27, ord("q")): + log("Interrotto da tastiera") + stop_requested = True + break + if wms_result is not None: + break + wms_wait_ms += (time.perf_counter() - wait_t0) * 1000.0 + else: + wait_t0 = time.perf_counter() + key = cv2.waitKey(max(1, int(args.remote_ack_timeout_sec * 1000))) & 0xFF + wms_wait_ms += (time.perf_counter() - wait_t0) * 1000.0 + if key in (27, ord("q")): + log("Interrotto da tastiera") + stop_requested = True + if stop_requested: + break + if args.wms_enabled: + navigator.apply_wms_result(wms_result, snapshot) + else: + navigator.simulate_remote_response(snapshot) + cv2.imshow( + "flywms comandi", + draw_commands_window(navigator.last_command_lines, navigator.motion_text), + ) + + navigator.label_movement_arrow = None + if stop_requested: + break + + ui_t0 = time.perf_counter() key = cv2.waitKey(1) & 0xFF + ui_ms += (time.perf_counter() - ui_t0) * 1000.0 if key in (27, ord("q")): log("Interrotto da tastiera") break + + loop_end = time.perf_counter() + elapsed = max(loop_end - start_time, 0.001) + loop_ms = (loop_end - frame_loop_start) * 1000.0 + active = sum(1 for t in tracks if t.missed == 0) + perf_writer.write( + f"{time.strftime('%Y-%m-%d %H:%M:%S')}\t{frame_id}\t{int(run_yolo)}\t" + f"{read_ms:.3f}\t{last_yolo_ms if run_yolo else 0.0:.3f}\t{track_ms:.3f}\t" + f"{draw_ms:.3f}\t{ui_ms:.3f}\t{snapshot_pause_ms:.3f}\t{wms_wait_ms:.3f}\t" + f"{loop_ms:.3f}\t{frame_id / elapsed:.3f}\t{yolo_cycles / elapsed:.3f}\t" + f"{format_fps_value(video_fps)}\t{format_fps_value(preview_fps)}\t{format_fps_value(args.yolo_fps)}\t" + f"{len(gaylords)}\t{len(labels)}\t{len(tracks)}\t{active}\t{navigator.snapshot_counter}\t" + f"{json.dumps(navigator.last_command_text, ensure_ascii=True)}" + ) finally: cap.release() + wms_client.close() + perf_writer.close() if not args.no_display: cv2.destroyAllWindows() diff --git a/flywms_navigation_gui.py b/flywms_navigation_gui.py index 3bc387c..a219fa1 100644 --- a/flywms_navigation_gui.py +++ b/flywms_navigation_gui.py @@ -53,13 +53,15 @@ class NavigationDemoEngine: def __init__(self, args): self.args = args nav.require_file(args.weights, "modello Ultralytics") - self.detector = nav.UltralyticsDetector(args.weights, args.ultralytics_device) + 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 = ( @@ -82,6 +84,7 @@ class NavigationDemoEngine: 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 = "" @@ -123,6 +126,10 @@ class NavigationDemoEngine: 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 ( @@ -140,6 +147,7 @@ class NavigationDemoEngine: frame, self.frame_id, timestamp, + self.labels, ) if snapshot is not None: new_snapshots.append(snapshot) @@ -149,17 +157,19 @@ class NavigationDemoEngine: elapsed = max(time.perf_counter() - self.start_time, 0.001) fps_text = ( - f"frame={self.frame_id} fps={self.frame_id / elapsed:.1f} " - f"yolo_fps={self.yolo_cycles / elapsed:.1f} yolo={self.last_yolo_ms:.0f}ms " - f"det={len(self.gaylords)} tracks={len(self.tracks)} " + 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.navigator.last_command_text, - fps_text, + self.labels, + self.navigator.label_movement_arrow, ) return DemoFrameState( @@ -335,7 +345,7 @@ class NavigationDemoGui: def bgr_to_rgba_float(frame: np.ndarray) -> np.ndarray: - rgba = cv2.cvtColor(frame, cv2.COLOR_BGR2RGBA) + rgba = nav.cuda_cvt_color(frame, cv2.COLOR_BGR2RGBA) return np.asarray(rgba, dtype=np.float32).ravel() / 255.0 diff --git a/flywms_paddleocr_worker.py b/flywms_paddleocr_worker.py new file mode 100644 index 0000000..b4407ac --- /dev/null +++ b/flywms_paddleocr_worker.py @@ -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()) diff --git a/flywms_wms_server.py b/flywms_wms_server.py new file mode 100644 index 0000000..b46dafe --- /dev/null +++ b/flywms_wms_server.py @@ -0,0 +1,789 @@ +import argparse +import asyncio +import configparser +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 + + +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( + "--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 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 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() + 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'}") + ui_thread = None + if args.ui_enabled: + ui_thread = threading.Thread(target=server_ui_loop, 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()) diff --git a/handoff.md b/handoff.md index 27ff2e7..8ed9c1b 100644 --- a/handoff.md +++ b/handoff.md @@ -1,116 +1,255 @@ -# FlyWMS handoff - GUI navigazione +# FlyWMS handoff -Data: 2026-05-15 +Data: 2026-05-18 ## Stato repository - Branch: `master` - Remote: `origin` su Gitea -- Ultimo lavoro: aggiunta shell DearPyGUI separata per la simulazione dimostrativa di `flywms_navigation.py`. +- Commit da creare per questa milestone: `pipeline in linea single thread` +- Tag git previsto per questa milestone: `pipeline-in-linea-single-thread` -## Contesto recuperato +## Stato funzionale attuale -L'obiettivo discusso era trasformare `flywms_navigation.py` in una simulazione piu' ordinata e dimostrativa, senza contaminare la logica esistente. Il programma originale contiene gia': +La base di lavoro e' ancora una pipeline sostanzialmente single-thread, con logica completa di navigazione/demo in Python. -- detector YOLO/Ultralytics; -- tracking geometrico leggero dei `gaylord`; -- decisione di snapshot quando una track e' centrata; -- salvataggio frame debug, payload OCR e `snapshots.jsonl`; -- simulazione comandi `STOP`, `SCATTA_FOTO`, `ASSOCIA_POSIZIONE`, `INVIA_ROI_REMOTA`, `ATTENDI_ACK`, `RIPARTI_*`; -- UI debug OpenCV integrata nel `main()`. +Componenti principali presenti: -## Decisione architetturale +- [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 -La GUI DearPyGUI e' stata tenuta in un file separato, `flywms_navigation_gui.py`. +## Stato GPU / librerie -Motivo: non toccare il ciclo originale e non rischiare regressioni nella logica di navigazione. La GUI importa `flywms_navigation.py` e riusa: +Verificato su questo PC: -- `UltralyticsDetector`; -- `LightweightTracker`; -- `NavigationController`; -- `draw_navigation_debug`; -- configurazione da `flywms_navigation.ini`. +- OpenCV con CUDA e GUI Win32 attivi +- cuDNN attivo +- YOLO/Ultralytics su GPU RTX 3050 +- `yolo_half = true` supportato -In pratica `flywms_navigation.py` resta il simulatore CLI/OpenCV funzionante; `flywms_navigation_gui.py` e' una shell dimostrativa sopra la stessa logica. +PaddleOCR al momento resta nel worker dedicato e non e' il focus della prossima rifattorizzazione. -## Implementato +## Configurazione importante -File nuovo: +File: [flywms_navigation.ini](C:/devel/flywms/flywms_navigation.ini) -- `flywms_navigation_gui.py` +Valori chiave attuali: -Funzioni principali: +- `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` -- `NavigationDemoEngine`: esegue un singolo step del ciclo video/YOLO/tracking/navigazione e restituisce uno stato frame-by-frame. -- `NavigationDemoGui`: gestisce finestra DearPyGUI, comandi Avvia/Pausa, Step, Reset, preview, metriche, comandi, payload OCR e track. -- conversione frame OpenCV BGR in texture RGBA DearPyGUI. -- gestione texture corretta con tag univoci quando cambia la dimensione immagine. -- resize visuale della preview principale a 960x540. +Il video di lavoro locale usato durante i test recenti e' `testhd2_edit.mp4`. +Nota: i file video locali non sono necessariamente da committare. -DearPyGUI e' stato installato nell'ambiente Python corrente con: +## 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 -m pip install dearpygui +python flywms_navigation.py --benchmark-mode ``` -## Verifiche eseguite +Caratteristiche del profilo benchmark: -Compilazione: +- 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 -m py_compile flywms_navigation_gui.py flywms_navigation.py +python flywms_wms_server.py ``` -Test motore GUI senza finestra persistente: +Demo navigazione con UI OpenCV: -```text -state1 1 (720, 1280, 3) -state2 2 (720, 1280, 3) +```powershell +python flywms_navigation.py --wms-enabled ``` -Test aggiornamento UI/texture: +Benchmark headless: -```text -ui step ok -960 540 +```powershell +python flywms_navigation.py --benchmark-mode ``` -Avvio manuale usato: +GUI DearPyGUI: ```powershell python flywms_navigation_gui.py ``` - -## Osservazioni performance - -Con CPU, la GUI scende circa da 6 fps a 3.8 fps. Il dato e' credibile perche' la shell GUI aggiunge: - -- disegno overlay OpenCV; -- conversione BGR -> RGBA; -- normalizzazione/copia texture `float32`; -- aggiornamento pannelli DearPyGUI nello stesso thread di YOLO. - -Il carico CPU alto su tutti gli 8 core e' credibile: PyTorch/OpenCV/NumPy usano thread nativi anche se il loop Python e' singolo. - -Quando sara' disponibile una GPU corretta, conviene rimisurare separando: - -- fps YOLO/tracking; -- fps GUI; -- tempo medio YOLO; -- tempo medio conversione texture. - -## Prossimi passi consigliati - -1. Migliorare la GUI come demo: - - timeline eventi `STOP`, `SCATTA_FOTO`, `ACK/NACK`, `RIPARTI`; - - pannello soglie principali; - - evidenza piu' leggibile dello stato `entering/candidate/centered/snapshotted`; - - contatori snapshot e posizioni simulate. - -2. Ottimizzare solo se serve: - - aggiornare texture a frequenza limitata; - - convertire per GUI un frame gia' ridotto; - - separare inferenza e GUI con stato latest-frame; - - aggiornare testo solo quando cambia. - -3. Non modificare la logica di `flywms_navigation.py` finche' la GUI non ha stabilizzato i requisiti dimostrativi.