Files
flywms/flywms_navigation_observer.py
2026-06-04 08:57:36 +02:00

516 lines
22 KiB
Python

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())