From aa17d7ffd3be0628338da75204395943ce18d0e8 Mon Sep 17 00:00:00 2001 From: administrator Date: Thu, 4 Jun 2026 08:57:36 +0200 Subject: [PATCH] Add DearPyGUI observer/server UI and demo launcher --- aggiornamento-2026-06-03-21-10.md | 61 +++++ dearpygui_observer_server_spec.md | 103 ++++++++ flywms_navigation_observer.py | 415 +++++++++++++++++++++++++++++- flywms_wms_server.py | 131 +++++++++- run_navigator.bat | 5 + run_observer.bat | 5 + run_wms_server.bat | 5 + startdemo.bat | 14 + startdemo.py | 138 ++++++++++ 9 files changed, 869 insertions(+), 8 deletions(-) create mode 100644 aggiornamento-2026-06-03-21-10.md create mode 100644 dearpygui_observer_server_spec.md create mode 100644 run_navigator.bat create mode 100644 run_observer.bat create mode 100644 run_wms_server.bat create mode 100644 startdemo.bat create mode 100644 startdemo.py diff --git a/aggiornamento-2026-06-03-21-10.md b/aggiornamento-2026-06-03-21-10.md new file mode 100644 index 0000000..3567607 --- /dev/null +++ b/aggiornamento-2026-06-03-21-10.md @@ -0,0 +1,61 @@ +## Aggiornamento 2026-06-03 21:10 + +### Obiettivo +Introdurre una UI DearPyGUI solo per i componenti esterni: +- observer di `flywms_navigation` +- server `flywms_wms_server` + +Lasciare invariata la parte intelligente del core: +- acquisizione +- inferenza +- tracking +- logica di navigazione e snapshot + +### Baseline salvata +- Commit locale: `e86c05a` +- Tag locale: `gui-observer-in-opencv` + +Nota: il push su Gitea non e' riuscito per problema di risoluzione DNS dell'host remoto. + +### Documentazione aggiunta +- `dearpygui_observer_server_spec.md` + +### Modifiche implementate + +#### `flywms_navigation_observer.py` +- aggiunto supporto backend UI: + - `dearpygui` + - `opencv` + - `auto` +- mantenuto il protocollo socket esistente con il core +- aggiunta UI DearPyGUI con: + - preview principale navigazione + - preview snapshot + - preview crop etichetta + - pannello stato + - pannello metriche + - pannello comandi +- mantenuto fallback OpenCV + +#### `flywms_wms_server.py` +- aggiunto supporto backend UI: + - `dearpygui` + - `opencv` + - `auto` +- lasciata invariata la logica FastAPI/OCR/ACK +- aggiunta UI DearPyGUI con: + - immagine ricevuta + - stato server + - payload OCR / WMS +- mantenuto fallback OpenCV + +### Verifiche eseguite +- `python -m py_compile flywms_navigation_observer.py flywms_wms_server.py` +- import e selezione backend: + - `flywms_navigation_observer.choose_backend('auto') -> dearpygui` + - `flywms_wms_server.choose_ui_backend('auto') -> dearpygui` + +### Stato attuale +- core intelligente invariato +- observer e server pronti per prova con DearPyGUI +- fallback OpenCV ancora disponibile per debug diff --git a/dearpygui_observer_server_spec.md b/dearpygui_observer_server_spec.md new file mode 100644 index 0000000..0bfd387 --- /dev/null +++ b/dearpygui_observer_server_spec.md @@ -0,0 +1,103 @@ +# DearPyGUI UI Spec - Observer e WMS Server + +## Scopo + +Rendere la demo piu' ordinata e leggibile spostando la presentazione visuale da OpenCV a DearPyGUI nei soli componenti esterni: + +- `flywms_navigation_observer.py` +- `flywms_wms_server.py` + +La parte intelligente resta invariata: + +- `flywms_navigation.py` +- tracking +- snapshot logic +- WMS client +- logica OCR / FastAPI lato server + +## Principio architetturale + +La UI non entra nel percorso critico. + +### Observer + +- riceve gia' telemetria e preview dal core; +- DearPyGUI sostituisce solo le finestre OpenCV; +- il protocollo socket e il payload restano invariati. + +### WMS Server + +- continua a ricevere upload e a fare OCR come oggi; +- DearPyGUI sostituisce solo le finestre OpenCV di monitoraggio; +- nessuna modifica al contratto HTTP. + +## Layout Observer + +### Colonna sinistra + +- preview principale `navigate` + +### Colonna destra superiore + +- snapshot OCR payload +- crop etichetta + +### Colonna destra inferiore + +- stato / metriche +- comando corrente +- lista comandi + +### Metriche minime + +- `frame_id` +- `processed_frames` se presente +- `loop_fps` +- `yolo_fps` +- `last_yolo_ms` +- `det_count` +- `track_count` +- `snapshot_counter` +- `yolo_mode` se disponibile +- `motion_text` + +## Layout WMS Server + +### Colonna sinistra + +- ultima immagine ricevuta dal server + +### Colonna destra + +- payload request corrente +- esito OCR +- esito ACK/NACK +- contatore richieste + +## Requisiti UI + +- finestre ridimensionabili +- nessun blocco sul core/server +- refresh periodico leggero +- fallback a OpenCV se DearPyGUI non e' disponibile + +## Strategia di implementazione + +### Fase 1 + +- introdurre backend DearPyGUI opzionale per observer +- introdurre backend DearPyGUI opzionale per WMS server +- mantenere OpenCV come fallback + +### Fase 2 + +- rifinire il layout +- eventuali colori/stati piu' chiari +- eventuali controlli aggiuntivi + +## Criterio di successo + +1. la demo risulta piu' ordinata; +2. il core intelligente non cambia comportamento; +3. observer e server possono essere lanciati senza finestre OpenCV; +4. il fallback OpenCV resta disponibile in caso di problema. diff --git a/flywms_navigation_observer.py b/flywms_navigation_observer.py index ba07329..2e073f0 100644 --- a/flywms_navigation_observer.py +++ b/flywms_navigation_observer.py @@ -1,5 +1,6 @@ import argparse import base64 +import ctypes import json import socket import time @@ -9,6 +10,44 @@ import numpy as np import flywms_navigation as nav +try: + import dearpygui.dearpygui as dpg +except ModuleNotFoundError: + dpg = None + + +NAV_TEXTURE = "obs_nav_texture" +SNAPSHOT_TEXTURE = "obs_snapshot_texture" +LABEL_TEXTURE = "obs_label_texture" +NAV_IMAGE = "obs_nav_image" +SNAPSHOT_IMAGE = "obs_snapshot_image" +LABEL_IMAGE = "obs_label_image" +TEXT_STATUS = "obs_status" +TEXT_COMMANDS = "obs_commands" +TEXT_TIMELINE = "obs_timeline" +BADGE_CONNECTION = "obs_badge_connection" +BADGE_YOLO = "obs_badge_yolo" +BADGE_PHASE = "obs_badge_phase" +BADGE_WMS = "obs_badge_wms" +METRIC_LOOP_FPS = "obs_metric_loop_fps" +METRIC_YOLO_FPS = "obs_metric_yolo_fps" +METRIC_YOLO_MS = "obs_metric_yolo_ms" +METRIC_SNAPSHOTS = "obs_metric_snapshots" +METRIC_FRAME_ID = "obs_metric_frame_id" +METRIC_TRACKS = "obs_metric_tracks" +METRIC_LABELS = "obs_metric_labels" +METRIC_PROGRESS = "obs_metric_progress" +NAV_W = 960 +NAV_H = 540 +SNAPSHOT_W = 520 +SNAPSHOT_H = 300 +LABEL_W = 520 +LABEL_H = 220 +THEME_BADGE_OK = "obs_theme_badge_ok" +THEME_BADGE_WARN = "obs_theme_badge_warn" +THEME_BADGE_ALERT = "obs_theme_badge_alert" +THEME_BADGE_IDLE = "obs_theme_badge_idle" + def parse_args(): pre = argparse.ArgumentParser(add_help=False) @@ -20,6 +59,8 @@ def parse_args(): ap.add_argument("--host", default=defaults["observer_host"], help="Host publisher observer") ap.add_argument("--port", type=int, default=defaults["observer_port"], help="Porta publisher observer") ap.add_argument("--reconnect-sec", type=float, default=1.0, help="Attesa tra tentativi di reconnessione") + ap.add_argument("--ui-backend", choices=["auto", "opencv", "dearpygui"], default="dearpygui", + help="Backend UI observer") return ap.parse_args() @@ -35,8 +76,360 @@ def decode_preview(message: dict[str, object]) -> np.ndarray | None: return None -def main() -> int: - args = parse_args() +def choose_backend(requested: str) -> str: + if requested == "opencv": + return "opencv" + if requested == "dearpygui": + if dpg is None: + raise RuntimeError("DearPyGUI non disponibile") + return "dearpygui" + if dpg is not None: + return "dearpygui" + return "opencv" + + +def extract_progress_text(telemetry: dict[str, object]) -> str: + pose = telemetry.get("capture_pose") if isinstance(telemetry.get("capture_pose"), dict) else {} + route_progress = pose.get("route_progress_ratio") + if isinstance(route_progress, (int, float)): + return f"{float(route_progress) * 100.0:.1f}%" + return "n/d" + + +def format_commands(telemetry: dict[str, object]) -> str: + command_lines = telemetry.get("command_lines") or ["Observer in attesa dati"] + if not isinstance(command_lines, list): + command_lines = [str(command_lines)] + return "\n".join(str(item) for item in command_lines) + + +def empty_placeholder(width: int, height: int, text: str) -> np.ndarray: + canvas = np.full((height, width, 3), 235, dtype=np.uint8) + cv2.putText( + canvas, + text, + (24, height // 2), + cv2.FONT_HERSHEY_SIMPLEX, + 0.8, + (30, 30, 30), + 2, + cv2.LINE_AA, + ) + return canvas + + +def frame_to_texture_data(frame: np.ndarray, target_w: int, target_h: int) -> list[float]: + src_h, src_w = frame.shape[:2] + if src_h <= 0 or src_w <= 0: + canvas = np.zeros((target_h, target_w, 3), dtype=np.uint8) + else: + scale = min(target_w / float(src_w), target_h / float(src_h)) + draw_w = max(1, int(round(src_w * scale))) + draw_h = max(1, int(round(src_h * scale))) + resized = cv2.resize(frame, (draw_w, draw_h), interpolation=cv2.INTER_AREA if scale < 1.0 else cv2.INTER_LINEAR) + canvas = np.full((target_h, target_w, 3), 18, dtype=np.uint8) + x0 = (target_w - draw_w) // 2 + y0 = (target_h - draw_h) // 2 + canvas[y0:y0 + draw_h, x0:x0 + draw_w] = resized + rgba = cv2.cvtColor(canvas, cv2.COLOR_BGR2RGBA) + return (rgba.astype(np.float32).ravel() / 255.0).tolist() + + +def restore_window_by_title(title: str) -> None: + if not hasattr(ctypes, "windll"): + return + user32 = ctypes.windll.user32 + hwnd = user32.FindWindowW(None, title) + if hwnd: + user32.ShowWindow(hwnd, 9) # SW_RESTORE + user32.SetForegroundWindow(hwnd) + + +def classify_phase(telemetry: dict[str, object]) -> str: + command_lines = telemetry.get("command_lines") + if not isinstance(command_lines, list): + command_lines = [] + joined = " | ".join(str(line) for line in command_lines) + if "ATTENDI_WMS" in joined or "ATTENDI_ACK" in joined: + return "WAIT WMS" + if "SCATTA_FOTO_ETICHETTA" in joined or "INVIA_ROI_REMOTA" in joined: + return "CAPTURE" + if "CENTRA_ETICHETTA" in joined or "RITORNA_CENTRO_GAYLORD" in joined: + return "CENTERING" + if "STOP" in joined: + return "PAUSED" + return "SCANNING" + + +def classify_wms_status(telemetry: dict[str, object]) -> str: + command_lines = telemetry.get("command_lines") + if not isinstance(command_lines, list): + command_lines = [] + joined = " | ".join(str(line) for line in command_lines) + if "WMS_ACK" in joined or "ACK_RICEVUTO" in joined: + return "ACK" + if "WMS_NACK" in joined or "NACK_RICEVUTO" in joined: + return "NACK" + if "WMS_TIMEOUT" in joined or "TIMEOUT" in joined: + return "TIMEOUT" + if "ATTENDI_WMS" in joined or "ATTENDI_ACK" in joined: + return "WAIT" + return "IDLE" + + +class DearPyGuiObserver: + def __init__(self, args): + if dpg is None: + raise RuntimeError("DearPyGUI non disponibile") + self.args = args + self.latest_navigate: np.ndarray | None = None + self.latest_snapshot: np.ndarray | None = None + self.latest_label: np.ndarray | None = None + self.telemetry: dict[str, object] = { + "command_lines": ["Observer in attesa dati"], + "motion_text": "MOTO: n/d", + } + self.sock: socket.socket | None = None + self.recv_buffer = "" + self.last_connect_attempt = 0.0 + self.connected = False + self.event_history: list[str] = [] + self.last_command_signature = "" + self.last_phase = "" + self.last_wms_status = "" + + def run(self) -> int: + self._build_ui() + try: + while dpg.is_dearpygui_running(): + self._poll_socket() + self._refresh_ui() + dpg.render_dearpygui_frame() + finally: + self._close_socket() + dpg.destroy_context() + return 0 + + def _build_ui(self) -> None: + dpg.create_context() + self._build_themes() + with dpg.texture_registry(show=False): + self._create_texture(NAV_TEXTURE, frame_to_texture_data(empty_placeholder(NAV_W, NAV_H, "In attesa preview"), NAV_W, NAV_H), NAV_W, NAV_H) + self._create_texture(SNAPSHOT_TEXTURE, frame_to_texture_data(empty_placeholder(SNAPSHOT_W, SNAPSHOT_H, "In attesa snapshot"), SNAPSHOT_W, SNAPSHOT_H), SNAPSHOT_W, SNAPSHOT_H) + self._create_texture(LABEL_TEXTURE, frame_to_texture_data(empty_placeholder(LABEL_W, LABEL_H, "In attesa etichetta"), LABEL_W, LABEL_H), LABEL_W, LABEL_H) + + with dpg.window(label="FlyWMS Observer", tag="main_window", width=1600, height=980): + with dpg.group(horizontal=True): + with dpg.child_window(width=980, height=920, border=True): + dpg.add_text("Preview navigazione") + dpg.add_image(NAV_TEXTURE, tag=NAV_IMAGE) + with dpg.child_window(width=580, height=920, border=True): + with dpg.group(horizontal=True): + dpg.add_button(label="CONN", tag=BADGE_CONNECTION, width=86, height=28, enabled=False) + dpg.add_button(label="YOLO", tag=BADGE_YOLO, width=86, height=28, enabled=False) + dpg.add_button(label="PHASE", tag=BADGE_PHASE, width=130, height=28, enabled=False) + dpg.add_button(label="WMS", tag=BADGE_WMS, width=86, height=28, enabled=False) + dpg.add_spacer(height=10) + dpg.add_text("Snapshot OCR payload") + dpg.add_image(SNAPSHOT_TEXTURE, tag=SNAPSHOT_IMAGE) + dpg.add_spacer(height=12) + dpg.add_text("Crop etichetta") + dpg.add_image(LABEL_TEXTURE, tag=LABEL_IMAGE) + dpg.add_spacer(height=12) + dpg.add_separator() + dpg.add_text("Stato observer") + dpg.add_text("Observer in attesa connessione", tag=TEXT_STATUS, wrap=540) + dpg.add_separator() + dpg.add_text("Metriche") + with dpg.table(header_row=False, borders_innerH=True, borders_outerH=True, borders_innerV=True, borders_outerV=True, resizable=False, policy=dpg.mvTable_SizingStretchProp): + dpg.add_table_column() + dpg.add_table_column() + for label, tag in ( + ("Loop FPS", METRIC_LOOP_FPS), + ("YOLO FPS", METRIC_YOLO_FPS), + ("YOLO ms", METRIC_YOLO_MS), + ("Snapshot", METRIC_SNAPSHOTS), + ("Frame", METRIC_FRAME_ID), + ("Track attive", METRIC_TRACKS), + ("Etichette", METRIC_LABELS), + ("Progresso", METRIC_PROGRESS), + ): + with dpg.table_row(): + dpg.add_text(label) + dpg.add_text("n/d", tag=tag) + dpg.add_separator() + dpg.add_text("Comandi") + dpg.add_input_text(tag=TEXT_COMMANDS, multiline=True, readonly=True, width=540, height=260, default_value="Observer in attesa dati") + dpg.add_separator() + dpg.add_text("Timeline eventi") + dpg.add_input_text(tag=TEXT_TIMELINE, multiline=True, readonly=True, width=540, height=180, default_value="In attesa eventi") + + dpg.create_viewport(title="FlyWMS Observer", width=1600, height=980) + dpg.setup_dearpygui() + dpg.show_viewport() + time.sleep(0.05) + restore_window_by_title("FlyWMS Observer") + self._set_badge(BADGE_CONNECTION, "DISCONNECTED", THEME_BADGE_ALERT) + self._set_badge(BADGE_YOLO, "YOLO OFF", THEME_BADGE_IDLE) + self._set_badge(BADGE_PHASE, "IDLE", THEME_BADGE_IDLE) + self._set_badge(BADGE_WMS, "WMS IDLE", THEME_BADGE_IDLE) + + def _build_themes(self) -> None: + def make_badge_theme(tag: str, button_color: tuple[int, int, int], text_color: tuple[int, int, int] = (255, 255, 255)) -> None: + with dpg.theme(tag=tag): + with dpg.theme_component(dpg.mvButton): + dpg.add_theme_color(dpg.mvThemeCol_Button, button_color) + dpg.add_theme_color(dpg.mvThemeCol_ButtonHovered, button_color) + dpg.add_theme_color(dpg.mvThemeCol_ButtonActive, button_color) + dpg.add_theme_color(dpg.mvThemeCol_Text, text_color) + dpg.add_theme_style(dpg.mvStyleVar_FrameRounding, 6) + dpg.add_theme_style(dpg.mvStyleVar_FramePadding, 8, 6) + + make_badge_theme(THEME_BADGE_OK, (40, 140, 70)) + make_badge_theme(THEME_BADGE_WARN, (196, 132, 21)) + make_badge_theme(THEME_BADGE_ALERT, (176, 52, 52)) + make_badge_theme(THEME_BADGE_IDLE, (80, 88, 102)) + + def _create_texture(self, tag: str, data: list[float], width: int, height: int) -> None: + dpg.add_dynamic_texture(width=width, height=height, default_value=data, tag=tag) + + def _set_texture(self, texture_tag: str, frame: np.ndarray, target_w: int, target_h: int) -> None: + dpg.set_value(texture_tag, frame_to_texture_data(frame, target_w, target_h)) + + def _set_badge(self, tag: str, label: str, theme_tag: str) -> None: + dpg.configure_item(tag, label=label) + dpg.bind_item_theme(tag, theme_tag) + + def _append_event(self, text: str) -> None: + stamp = time.strftime("%H:%M:%S") + self.event_history.insert(0, f"{stamp} {text}") + self.event_history = self.event_history[:12] + + def _poll_socket(self) -> None: + now = time.perf_counter() + if self.sock is None: + if now - self.last_connect_attempt < max(0.2, self.args.reconnect_sec): + return + self.last_connect_attempt = now + try: + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.sock.connect((self.args.host, self.args.port)) + self.sock.setblocking(False) + self.recv_buffer = "" + self.connected = True + nav.log("[OBS] connesso al core") + self._append_event("Observer connesso al core") + except OSError as exc: + self._close_socket() + self.connected = False + dpg.set_value(TEXT_STATUS, f"Observer non connesso: {exc}") + return + + try: + while True: + try: + chunk = self.sock.recv(65536) + if not chunk: + raise ConnectionError("publisher disconnesso") + self.recv_buffer += chunk.decode("utf-8", errors="replace") + except BlockingIOError: + break + except OSError as exc: + if getattr(exc, "winerror", None) in (10035,): + break + raise + while True: + newline_pos = self.recv_buffer.find("\n") + if newline_pos < 0: + break + line = self.recv_buffer[:newline_pos].strip() + self.recv_buffer = self.recv_buffer[newline_pos + 1:] + if not line: + continue + try: + message = json.loads(line) + except json.JSONDecodeError: + continue + msg_type = message.get("type") + if msg_type == "telemetry": + self.telemetry = message + elif msg_type == "preview": + frame = decode_preview(message) + stream = message.get("stream") + if frame is None: + continue + if stream == "navigate": + self.latest_navigate = frame + elif stream == "snapshot": + self.latest_snapshot = frame + elif stream == "label": + self.latest_label = frame + except (ConnectionError, OSError) as exc: + nav.log(f"[OBS] connessione persa o non disponibile: {exc}") + self._close_socket() + self.connected = False + dpg.set_value(TEXT_STATUS, f"Connessione persa: {exc}") + self._append_event(f"Connessione persa: {exc}") + + def _refresh_ui(self) -> None: + if self.latest_navigate is not None: + self._set_texture(NAV_TEXTURE, self.latest_navigate, NAV_W, NAV_H) + if self.latest_snapshot is not None: + self._set_texture(SNAPSHOT_TEXTURE, self.latest_snapshot, SNAPSHOT_W, SNAPSHOT_H) + if self.latest_label is not None: + self._set_texture(LABEL_TEXTURE, self.latest_label, LABEL_W, LABEL_H) + state = "connesso" if self.connected else "in attesa connessione" + dpg.set_value(TEXT_STATUS, f"Observer {state} su {self.args.host}:{self.args.port}") + dpg.set_value(TEXT_COMMANDS, format_commands(self.telemetry)) + dpg.set_value(METRIC_LOOP_FPS, f"{float(self.telemetry.get('loop_fps', 0.0)):.2f}") + dpg.set_value(METRIC_YOLO_FPS, f"{float(self.telemetry.get('yolo_fps', 0.0)):.2f}") + dpg.set_value(METRIC_YOLO_MS, f"{float(self.telemetry.get('last_yolo_ms', 0.0)):.1f}") + dpg.set_value(METRIC_SNAPSHOTS, str(self.telemetry.get("snapshot_counter", 0))) + dpg.set_value(METRIC_FRAME_ID, str(self.telemetry.get("frame_id", "n/d"))) + dpg.set_value(METRIC_TRACKS, f"{self.telemetry.get('active_track_count', 0)} / {self.telemetry.get('track_count', 0)}") + dpg.set_value(METRIC_LABELS, str(self.telemetry.get("label_count", 0))) + dpg.set_value(METRIC_PROGRESS, extract_progress_text(self.telemetry)) + + self._set_badge(BADGE_CONNECTION, "CONNECTED" if self.connected else "DISCONNECTED", THEME_BADGE_OK if self.connected else THEME_BADGE_ALERT) + run_yolo = bool(self.telemetry.get("run_yolo", False)) + yolo_mode = str(self.telemetry.get("yolo_mode", "idle")).upper() + self._set_badge(BADGE_YOLO, f"YOLO {yolo_mode}" if run_yolo else "YOLO IDLE", THEME_BADGE_OK if run_yolo else THEME_BADGE_IDLE) + phase = classify_phase(self.telemetry) + phase_theme = THEME_BADGE_WARN if phase in ("CAPTURE", "CENTERING", "WAIT WMS", "PAUSED") else THEME_BADGE_OK + self._set_badge(BADGE_PHASE, phase, phase_theme) + wms_status = classify_wms_status(self.telemetry) + wms_theme = THEME_BADGE_IDLE + if wms_status == "ACK": + wms_theme = THEME_BADGE_OK + elif wms_status in ("WAIT",): + wms_theme = THEME_BADGE_WARN + elif wms_status in ("NACK", "TIMEOUT"): + wms_theme = THEME_BADGE_ALERT + self._set_badge(BADGE_WMS, f"WMS {wms_status}", wms_theme) + + command_signature = str(self.telemetry.get("command_text") or "") + if command_signature and command_signature != self.last_command_signature: + self._append_event(command_signature) + self.last_command_signature = command_signature + if phase != self.last_phase: + self._append_event(f"Fase -> {phase}") + self.last_phase = phase + if wms_status != self.last_wms_status: + self._append_event(f"Stato WMS -> {wms_status}") + self.last_wms_status = wms_status + dpg.set_value(TEXT_TIMELINE, "\n".join(self.event_history) if self.event_history else "In attesa eventi") + + def _close_socket(self) -> None: + try: + if self.sock is not None: + self.sock.close() + except OSError: + pass + self.sock = None + self.recv_buffer = "" + + +def run_opencv_observer(args) -> int: nav.log(f"[OBS] avvio observer {args.host}:{args.port}") latest_navigate: np.ndarray | None = None @@ -84,11 +477,10 @@ def main() -> int: cv2.imshow("flywms observer snapshot", latest_snapshot) if latest_label is not None: cv2.imshow("flywms observer etichetta", latest_label) - command_lines = telemetry.get("command_lines") or ["Observer attivo"] - if not isinstance(command_lines, list): - command_lines = [str(command_lines)] - motion_text = str(telemetry.get("motion_text") or "MOTO: n/d") - panel = nav.draw_commands_window([str(item) for item in command_lines], motion_text) + panel = nav.draw_commands_window( + [str(item) for item in (telemetry.get("command_lines") or ["Observer attivo"])], + str(telemetry.get("motion_text") or "MOTO: n/d"), + ) cv2.imshow("flywms observer comandi", panel) key = cv2.waitKey(1) & 0xFF if key in (27, ord("q")): @@ -110,5 +502,14 @@ def main() -> int: pass +def main() -> int: + args = parse_args() + backend = choose_backend(args.ui_backend) + nav.log(f"[OBS] backend UI: {backend}") + if backend == "dearpygui": + return DearPyGuiObserver(args).run() + return run_opencv_observer(args) + + if __name__ == "__main__": raise SystemExit(main()) diff --git a/flywms_wms_server.py b/flywms_wms_server.py index b46dafe..ed6de23 100644 --- a/flywms_wms_server.py +++ b/flywms_wms_server.py @@ -1,6 +1,7 @@ import argparse import asyncio import configparser +import ctypes import json import random import re @@ -17,6 +18,11 @@ import numpy as np import uvicorn from fastapi import FastAPI, File, Form, UploadFile +try: + import dearpygui.dearpygui as dpg +except ModuleNotFoundError: + dpg = None + DEFAULT_CONFIG_PATH = "flywms_navigation.ini" CUDA_MIN_PIXELS = 640 * 360 @@ -258,6 +264,8 @@ def parse_args(): ap.add_argument("--host", default=defaults["wms_server_host"], help="Host bind server") ap.add_argument("--port", type=int, default=defaults["wms_server_port"], help="Porta server") ap.add_argument("--received-dir", default=defaults["wms_received_dir"], help="Directory upload ricevuti") + ap.add_argument("--ui-backend", choices=["auto", "opencv", "dearpygui"], default="dearpygui", + help="Backend UI server WMS") ap.add_argument( "--fake-ack-mode", choices=["always-ack", "always-nack", "alternate", "random"], @@ -670,6 +678,60 @@ def safe_name(value: str) -> str: return "".join(ch if ch in allowed else "_" for ch in value)[:160] +def choose_ui_backend(requested: str) -> str: + if requested == "opencv": + return "opencv" + if requested == "dearpygui": + if dpg is None: + raise RuntimeError("DearPyGUI non disponibile") + return "dearpygui" + if dpg is not None: + return "dearpygui" + return "opencv" + + +def placeholder_frame(width: int, height: int, text: str) -> np.ndarray: + canvas = np.full((height, width, 3), 235, dtype=np.uint8) + cv2.putText( + canvas, + text, + (24, height // 2), + cv2.FONT_HERSHEY_SIMPLEX, + 0.8, + (30, 30, 30), + 2, + cv2.LINE_AA, + ) + return canvas + + +def frame_to_texture_data(frame: np.ndarray, target_w: int, target_h: int) -> list[float]: + src_h, src_w = frame.shape[:2] + if src_h <= 0 or src_w <= 0: + canvas = np.zeros((target_h, target_w, 3), dtype=np.uint8) + else: + scale = min(target_w / float(src_w), target_h / float(src_h)) + draw_w = max(1, int(round(src_w * scale))) + draw_h = max(1, int(round(src_h * scale))) + resized = cv2.resize(frame, (draw_w, draw_h), interpolation=cv2.INTER_AREA if scale < 1.0 else cv2.INTER_LINEAR) + canvas = np.full((target_h, target_w, 3), 18, dtype=np.uint8) + x0 = (target_w - draw_w) // 2 + y0 = (target_h - draw_h) // 2 + canvas[y0:y0 + draw_h, x0:x0 + draw_w] = resized + rgba = cv2.cvtColor(canvas, cv2.COLOR_BGR2RGBA) + return (rgba.astype(np.float32).ravel() / 255.0).tolist() + + +def restore_window_by_title(title: str) -> None: + if not hasattr(ctypes, "windll"): + return + user32 = ctypes.windll.user32 + hwnd = user32.FindWindowW(None, title) + if hwnd: + user32.ShowWindow(hwnd, 9) # SW_RESTORE + user32.SetForegroundWindow(hwnd) + + def server_ui_loop() -> None: try: cv2.namedWindow("wms immagine ricevuta", cv2.WINDOW_NORMAL) @@ -714,6 +776,70 @@ def server_ui_loop() -> None: cv2.destroyWindow("wms payload") +def server_dpg_ui_loop() -> None: + if dpg is None: + log("UI DearPyGUI non disponibile, impossibile avviare il monitor WMS") + with ui_lock: + ui_state["stop"] = True + return + + image_texture = "wms_image_texture" + payload_text = "wms_payload_text" + status_text = "wms_status_text" + image_w = 720 + image_h = 420 + + def create_texture(tag: str, data: list[float], width: int, height: int) -> None: + dpg.add_dynamic_texture(width=width, height=height, default_value=data, tag=tag) + + def set_texture(frame: np.ndarray) -> None: + dpg.set_value(image_texture, frame_to_texture_data(frame, image_w, image_h)) + + dpg.create_context() + with dpg.texture_registry(show=False): + create_texture( + image_texture, + frame_to_texture_data(placeholder_frame(image_w, image_h, "In attesa immagine etichetta"), image_w, image_h), + image_w, + image_h, + ) + + with dpg.window(label="FlyWMS WMS Server", width=1450, height=900): + with dpg.group(horizontal=True): + with dpg.child_window(width=760, height=840, border=True): + dpg.add_text("Ultima immagine ricevuta") + dpg.add_image(image_texture) + with dpg.child_window(width=640, height=840, border=True): + dpg.add_text("Stato") + dpg.add_text("Server WMS in attesa payload", tag=status_text, wrap=600) + dpg.add_separator() + dpg.add_text("Payload") + dpg.add_input_text(tag=payload_text, multiline=True, readonly=True, width=600, height=720, default_value="In attesa di payload WMS") + + dpg.create_viewport(title="FlyWMS WMS Server", width=1450, height=900) + dpg.setup_dearpygui() + dpg.show_viewport() + time.sleep(0.05) + restore_window_by_title("FlyWMS WMS Server") + + try: + while dpg.is_dearpygui_running(): + with ui_lock: + stop = bool(ui_state["stop"]) + image = None if ui_state["image"] is None else ui_state["image"].copy() + lines = list(ui_state["payload_lines"]) + if stop: + break + if image is not None: + set_texture(image) + dpg.set_value(status_text, f"Richieste ricevute: {server_state['counter']}") + dpg.set_value(payload_text, "\n".join(lines)) + dpg.render_dearpygui_frame() + time.sleep(0.03) + finally: + dpg.destroy_context() + + def resize_preview(frame: np.ndarray, max_width: int) -> np.ndarray: h, w = frame.shape[:2] if max_width <= 0 or w <= max_width: @@ -745,6 +871,7 @@ def draw_payload_window(lines: list[str]) -> np.ndarray: def main() -> int: args = parse_args() + ui_backend = choose_ui_backend(args.ui_backend) if args.ui_enabled else "disabled" server_state["received_dir"] = args.received_dir server_state["fake_ack_mode"] = args.fake_ack_mode server_state["fake_processing_sec"] = args.fake_processing_sec @@ -770,9 +897,11 @@ def main() -> int: f"mode={args.fake_ack_mode} received_dir={args.received_dir}" ) log(f"OpenCV CUDA: {'attivo' if OPENCV_CUDA_AVAILABLE else 'non disponibile'}") + log(f"UI backend: {ui_backend}") ui_thread = None if args.ui_enabled: - ui_thread = threading.Thread(target=server_ui_loop, name="wms-server-ui", daemon=True) + ui_target = server_dpg_ui_loop if ui_backend == "dearpygui" else server_ui_loop + ui_thread = threading.Thread(target=ui_target, name="wms-server-ui", daemon=True) ui_thread.start() try: uvicorn.run(app, host=args.host, port=args.port, log_level="info") diff --git a/run_navigator.bat b/run_navigator.bat new file mode 100644 index 0000000..98b5c7e --- /dev/null +++ b/run_navigator.bat @@ -0,0 +1,5 @@ +@echo off +title FlyWMS - Navigator +cd /d "%~dp0" +python flywms_navigation.py --video "testhd2_edit.mp4" --observer-enabled --wms-enabled + diff --git a/run_observer.bat b/run_observer.bat new file mode 100644 index 0000000..2fc34ce --- /dev/null +++ b/run_observer.bat @@ -0,0 +1,5 @@ +@echo off +title FlyWMS - Observer +cd /d "%~dp0" +python flywms_navigation_observer.py --ui-backend dearpygui + diff --git a/run_wms_server.bat b/run_wms_server.bat new file mode 100644 index 0000000..b5f1a52 --- /dev/null +++ b/run_wms_server.bat @@ -0,0 +1,5 @@ +@echo off +title FlyWMS - WMS Server +cd /d "%~dp0" +python flywms_wms_server.py --ui-backend dearpygui + diff --git a/startdemo.bat b/startdemo.bat new file mode 100644 index 0000000..f1c2a21 --- /dev/null +++ b/startdemo.bat @@ -0,0 +1,14 @@ +@echo off +setlocal + +set "ROOT=%~dp0" +set "PY_SCRIPT=%ROOT%startdemo.py" + +if not exist "%PY_SCRIPT%" ( + echo Script non trovato: "%PY_SCRIPT%" + exit /b 1 +) + +python "%PY_SCRIPT%" +set "EXIT_CODE=%ERRORLEVEL%" +exit /b %EXIT_CODE% diff --git a/startdemo.py b/startdemo.py new file mode 100644 index 0000000..a16de63 --- /dev/null +++ b/startdemo.py @@ -0,0 +1,138 @@ +import os +import signal +import subprocess +import sys +import time +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent +VIDEO = "testhd2_edit.mp4" + +CHILDREN: list[subprocess.Popen] = [] +STOPPING = False + + +def kill_tree(pid: int) -> None: + try: + subprocess.run( + ["taskkill", "/PID", str(pid), "/T", "/F"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + except Exception: + pass + + +def pids_listening_on_port(port: int) -> list[int]: + try: + result = subprocess.run( + ["netstat", "-ano", "-p", "tcp"], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + check=False, + ) + except Exception: + return [] + pids: set[int] = set() + suffix = f":{port}" + for raw_line in result.stdout.splitlines(): + line = raw_line.strip() + if not line: + continue + parts = line.split() + if len(parts) < 5: + continue + proto, local_addr, _, state, pid_text = parts[:5] + if proto.upper() != "TCP": + continue + if state.upper() != "LISTENING": + continue + if not local_addr.endswith(suffix): + continue + try: + pids.add(int(pid_text)) + except ValueError: + continue + return sorted(pids) + + +def preflight_cleanup() -> None: + stale_pids = pids_listening_on_port(8088) + for pid in stale_pids: + print(f"Pulizia preventiva: termino processo in ascolto su 8088 (PID {pid})", flush=True) + kill_tree(pid) + if stale_pids: + time.sleep(0.4) + + +def stop_all() -> None: + global STOPPING + if STOPPING: + return + STOPPING = True + print("\nArresto finestre demo...", flush=True) + for proc in CHILDREN: + if proc.poll() is None: + kill_tree(proc.pid) + + +def on_signal(signum, frame) -> None: + stop_all() + raise KeyboardInterrupt + + +def start_window(title: str, launcher_bat: str) -> subprocess.Popen: + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + startupinfo.wShowWindow = 6 # SW_MINIMIZE + return subprocess.Popen( + ["cmd.exe", "/k", launcher_bat], + cwd=str(ROOT), + creationflags=subprocess.CREATE_NEW_CONSOLE, + startupinfo=startupinfo, + ) + + +def main() -> int: + signal.signal(signal.SIGINT, on_signal) + if hasattr(signal, "SIGTERM"): + signal.signal(signal.SIGTERM, on_signal) + + preflight_cleanup() + + try: + print("Avvio WMS server...", flush=True) + CHILDREN.append(start_window("FlyWMS - WMS Server", "run_wms_server.bat")) + time.sleep(0.25) + + print("Avvio observer...", flush=True) + CHILDREN.append(start_window("FlyWMS - Observer", "run_observer.bat")) + time.sleep(0.25) + + print("Avvio navigator...", flush=True) + CHILDREN.append(start_window("FlyWMS - Navigator", "run_navigator.bat")) + + print("\nDemo avviata.", flush=True) + for proc in CHILDREN: + print(f"- PID {proc.pid}", flush=True) + print("\nPremi Ctrl+C in questa finestra per chiudere tutto.\n", flush=True) + + while True: + alive = [proc for proc in CHILDREN if proc.poll() is None] + if not alive: + print("Tutte le finestre demo sono terminate.", flush=True) + return 0 + time.sleep(1.0) + except KeyboardInterrupt: + print("Interruzione rilevata.", flush=True) + return 130 + finally: + stop_all() + + +if __name__ == "__main__": + raise SystemExit(main())