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