diff --git a/flywms_navigation_gui.py b/flywms_navigation_gui.py new file mode 100644 index 0000000..871bace --- /dev/null +++ b/flywms_navigation_gui.py @@ -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()) diff --git a/handoff.md b/handoff.md new file mode 100644 index 0000000..27ff2e7 --- /dev/null +++ b/handoff.md @@ -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.