aggiunta gui

This commit is contained in:
administrator
2026-05-15 20:39:06 +02:00
parent 8a8bea1211
commit 16458d98e9
2 changed files with 519 additions and 0 deletions

403
flywms_navigation_gui.py Normal file
View File

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

116
handoff.md Normal file
View File

@@ -0,0 +1,116 @@
# FlyWMS handoff - GUI navigazione
Data: 2026-05-15
## Stato repository
- Branch: `master`
- Remote: `origin` su Gitea
- Ultimo lavoro: aggiunta shell DearPyGUI separata per la simulazione dimostrativa di `flywms_navigation.py`.
## Contesto recuperato
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':
- 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()`.
## Decisione architetturale
La GUI DearPyGUI e' stata tenuta in un file separato, `flywms_navigation_gui.py`.
Motivo: non toccare il ciclo originale e non rischiare regressioni nella logica di navigazione. La GUI importa `flywms_navigation.py` e riusa:
- `UltralyticsDetector`;
- `LightweightTracker`;
- `NavigationController`;
- `draw_navigation_debug`;
- configurazione da `flywms_navigation.ini`.
In pratica `flywms_navigation.py` resta il simulatore CLI/OpenCV funzionante; `flywms_navigation_gui.py` e' una shell dimostrativa sopra la stessa logica.
## Implementato
File nuovo:
- `flywms_navigation_gui.py`
Funzioni principali:
- `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.
DearPyGUI e' stato installato nell'ambiente Python corrente con:
```powershell
python -m pip install dearpygui
```
## Verifiche eseguite
Compilazione:
```powershell
python -m py_compile flywms_navigation_gui.py flywms_navigation.py
```
Test motore GUI senza finestra persistente:
```text
state1 1 (720, 1280, 3)
state2 2 (720, 1280, 3)
```
Test aggiornamento UI/texture:
```text
ui step ok
960 540
```
Avvio manuale usato:
```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.