diff --git a/aggiornamento-2026-05-29-16-38.md b/aggiornamento-2026-05-29-16-38.md new file mode 100644 index 0000000..a114922 --- /dev/null +++ b/aggiornamento-2026-05-29-16-38.md @@ -0,0 +1,86 @@ +# Aggiornamento 2026-05-29 16:38 + +## Step 1 - Core / Observer + +Completati: + +- documento di progetto Step 1 in [step1_core_observer_design.md](/C:/devel/flywms/step1_core_observer_design.md) +- separazione iniziale tra core runtime e observer esterno +- configurazione observer aggiunta in [flywms_navigation.ini](/C:/devel/flywms/flywms_navigation.ini) +- nuovo entrypoint observer in [flywms_navigation_observer.py](/C:/devel/flywms/flywms_navigation_observer.py) + +## Modifiche implementate + +In [flywms_navigation.py](/C:/devel/flywms/flywms_navigation.py): + +- aggiunto `ObserverPublisher` +- il core puo' pubblicare su `localhost`: + - telemetria strutturata + - preview JPEG a bassa frequenza +- quando `observer_enabled = true`: + - la UI locale integrata viene disattivata + - il core non aspetta l'observer + - l'observer puo' essere assente o disconnettersi senza bloccare la pipeline +- la sequenza snapshot headless e' stata resa osservabile per fasi: + - `move` + - `stabilize` + - `capture` + - `return` + - `wait_wms` + +In [flywms_navigation_observer.py](/C:/devel/flywms/flywms_navigation_observer.py): + +- processo separato +- connessione TCP locale al core +- visualizzazione di: + - preview navigazione + - pannello comandi/stato + - snapshot + - crop etichetta + +## Verifiche eseguite + +1. Compilazione Python: + +```bash +python -m py_compile flywms_navigation.py flywms_navigation_observer.py +``` + +Esito: ok. + +2. Smoke test core con observer attivo ma non collegato: + +```bash +python flywms_navigation.py --video testhd2_edit.mp4 --observer-enabled --max-frames 30 +``` + +Esito: + +- il core parte regolarmente +- la UI locale viene disattivata come previsto +- il publisher si mette in ascolto su `127.0.0.1:8765` +- il run chiude senza blocchi anche senza observer collegato + +## Stato attuale + +Lo Step 1 e' implementato nella sua forma iniziale e il lato critico del core e' verificato. + +Resta da fare una verifica manuale della GUI observer in esecuzione reale, cioe': + +1. avviare `flywms_navigation_observer.py` +2. avviare il core con `--observer-enabled` +3. controllare fluidita', frequenza preview e correttezza dei pannelli + +## Comandi di avvio previsti + +Observer: + +```bash +python flywms_navigation_observer.py +``` + +Core: + +```bash +python flywms_navigation.py --video testhd2_edit.mp4 --observer-enabled +``` diff --git a/aggiornamento-2026-05-30-18-12.md b/aggiornamento-2026-05-30-18-12.md new file mode 100644 index 0000000..5a62a41 --- /dev/null +++ b/aggiornamento-2026-05-30-18-12.md @@ -0,0 +1,75 @@ +# Aggiornamento 2026-05-30 18:12 + +## Step 2 - Capture / Inference + +Completati: + +- documento di progetto Step 2 in [step2_capture_inference_design.md](/C:/devel/flywms/step2_capture_inference_design.md) +- introduzione del pacchetto `CapturedFrame` +- introduzione del `CaptureWorker` +- propagazione della posa congelata dal capture fino allo snapshot + +## Modifiche implementate + +In [flywms_navigation.py](/C:/devel/flywms/flywms_navigation.py): + +- aggiunto `CapturedFrame` con: + - `frame_id` + - `timestamp` + - `video_time_sec` + - `frame` + - `pose` + - `read_ms` +- aggiunto `sample_demo_pose(...)` +- aggiunto `CaptureWorker` con coda `maxsize=1` +- politica di coda: `latest frame wins` +- `cap.read()` spostato fuori dal loop principale +- `NavigationController.process_track(...)` ora consuma `CapturedFrame` +- `CandidateSnapshot` e `NavigationSnapshot` ora portano anche `capture_pose` +- metadata snapshot JSON aggiornati: `drone_pose_simulated` ora deriva dal frame catturato + +## Correzione importante sulle metriche + +Con `latest frame wins`, il `frame_id` della sorgente puo' saltare. Per questo: + +- `frame_id` resta l'identificativo del frame sorgente; +- `processed_frames` misura invece quanti frame il core ha davvero consumato. + +Le metriche `loop_fps` e i log prestazionali ora usano `processed_frames`, non `frame_id`. + +## Verifiche eseguite + +1. Compilazione Python: + +```bash +python -m py_compile flywms_navigation.py flywms_navigation_observer.py +``` + +Esito: ok. + +2. Smoke test core senza observer: + +```bash +python flywms_navigation.py --video testhd2_edit.mp4 --max-frames 30 +``` + +Esito: ok. + +3. Smoke test core con observer attivo: + +```bash +python flywms_navigation.py --video testhd2_edit.mp4 --observer-enabled --max-frames 30 +``` + +Esito: ok. + +## Nota operativa + +Dopo lo Step 2, `--max-frames` resta un limite sui frame della sorgente video, non sui frame effettivamente processati. + +Questo va bene per smoke test rapidi, ma non e' il modo corretto per giudicare la resa finale della pipeline realtime. Il benchmark successivo va fatto sulla durata completa del video, confrontando: + +- durata nominale video; +- durata effettiva demo; +- `processed_frames`; +- `yolo_fps` reale. diff --git a/aggiornamento-2026-05-30-19-08.md b/aggiornamento-2026-05-30-19-08.md new file mode 100644 index 0000000..44617cc --- /dev/null +++ b/aggiornamento-2026-05-30-19-08.md @@ -0,0 +1,73 @@ +# Aggiornamento 2026-05-30 19:08 + +## Step 3 - Inferenza adattiva + +Documentazione: + +- [step3_adaptive_inference_design.md](/C:/devel/flywms/step3_adaptive_inference_design.md) + +Implementato: + +- scheduler YOLO adattivo con tre stati: + - `idle` + - `tracking` + - `critical` +- parametri configurabili: + - `adaptive_yolo_enabled` + - `idle_yolo_fps` + - `tracking_yolo_fps` + - `critical_yolo_fps` +- telemetria observer aggiornata con `yolo_mode` + +## Esito benchmark + +Run completo con: + +- `adaptive_yolo_enabled = true` +- `idle = 8` +- `tracking = 12` +- `critical = 15` + +Risultato finale: + +- durata demo totale: `874.73 s` +- durata demo netta senza WMS: `796.71 s` +- durata video: `658.14 s` +- scostamento netto: `+138.57 s` = `+21.1%` + +Confronto con run precedente senza adattivo: + +- netto precedente: `756.18 s` +- netto con adattivo: `796.71 s` +- peggioramento: circa `+40.5 s` + +## Interpretazione + +La riduzione della frequenza YOLO nei tratti idle non ha compensato l'effetto collaterale introdotto sul comportamento del tracking e della logica snapshot. + +In pratica: + +- il costo medio YOLO e' sceso; +- ma il comportamento globale della missione e' peggiorato; +- quindi questa prima forma di inferenza adattiva non e' vantaggiosa. + +## Decisione + +La feature resta disponibile nel codice come opzione sperimentale, ma viene disattivata di default: + +- [flywms_navigation.py](/C:/devel/flywms/flywms_navigation.py) +- [flywms_navigation.ini](/C:/devel/flywms/flywms_navigation.ini) + +Default attuale: + +- `adaptive_yolo_enabled = false` + +## Conclusione + +Lo Step 3, nella forma provata oggi, non va adottato come ottimizzazione di default. + +Se si vorra' tornare su questa strada, servira' una strategia piu' mirata, ad esempio: + +- adattamento solo in assenza totale di target per un certo tempo; +- nessuna riduzione di frequenza quando esistono track vive; +- oppure un criterio basato su zone del video predefinite, non solo sullo stato istantaneo delle track. diff --git a/flywms_navigation.ini b/flywms_navigation.ini index 4a3139a..4ac1003 100644 --- a/flywms_navigation.ini +++ b/flywms_navigation.ini @@ -120,7 +120,7 @@ snapshot_output_dir = navigate_snapshots ; Ruolo: tempo massimo con cui il drone attende OCR remoto + verifica WMS. ; Con PaddleOCR il primo avvio del worker puo' richiedere alcuni secondi. ; Default se non indicato: 2.0 -remote_ack_timeout_sec = 10.0 +remote_ack_timeout_sec = 3.0 ; OBBLIGATORIO: no. ; Ruolo: risposta remota simulata. Valori: always-ack, always-nack, alternate. @@ -324,6 +324,26 @@ yolo_half = true ; Default se non indicato: 15.0 yolo_fps = 15.0 +; OBBLIGATORIO: no. +; Ruolo: se true, la frequenza YOLO viene adattata allo stato delle track. +; Default se non indicato: true +adaptive_yolo_enabled = false + +; OBBLIGATORIO: no. +; Ruolo: FPS YOLO quando non ci sono track attive. +; Default se non indicato: 8.0 +idle_yolo_fps = 8.0 + +; OBBLIGATORIO: no. +; Ruolo: FPS YOLO quando ci sono track attive ma nessuna e' ancora critica. +; Default se non indicato: 12.0 +tracking_yolo_fps = 12.0 + +; OBBLIGATORIO: no. +; Ruolo: FPS YOLO quando una track e' in stato candidate/centered. +; Default se non indicato: 15.0 +critical_yolo_fps = 15.0 + ; OBBLIGATORIO: no. ; Ruolo: massimo numero di frame da processare. 0 significa tutto il video. ; Default se non indicato: 0 @@ -374,6 +394,42 @@ flash_alpha = 0.70 ; Default se non indicato: false no_display = false +; OBBLIGATORIO: no. +; Ruolo: se true, avvia la pubblicazione dei dati verso un observer esterno su socket locale. +; Quando attivo, il core disabilita la UI OpenCV integrata per non rallentare la demo. +; Default se non indicato: false +observer_enabled = false + +; OBBLIGATORIO: no. +; Ruolo: host TCP del publisher observer. +; Default se non indicato: 127.0.0.1 +observer_host = 127.0.0.1 + +; OBBLIGATORIO: no. +; Ruolo: porta TCP del publisher observer. +; Default se non indicato: 8765 +observer_port = 8765 + +; OBBLIGATORIO: no. +; Ruolo: FPS massimi della preview inviata all'observer. Tenere basso per non pesare sul core. +; Default se non indicato: 4.0 +observer_preview_fps = 4.0 + +; OBBLIGATORIO: no. +; Ruolo: larghezza massima della preview inviata all'observer. +; Default se non indicato: 960 +observer_preview_width = 960 + +; OBBLIGATORIO: no. +; Ruolo: qualita' JPEG delle preview inviate all'observer. +; Default se non indicato: 75 +observer_jpeg_quality = 75 + +; OBBLIGATORIO: no. +; Ruolo: frequenza della telemetria inviata all'observer. +; Default se non indicato: 8.0 +observer_telemetry_fps = 8.0 + ; OBBLIGATORIO: no. ; Ruolo: se true, posiziona e ridimensiona le finestre OpenCV usando i valori sotto. ; Formato finestre: x,y,width,height diff --git a/flywms_navigation.py b/flywms_navigation.py index f23da93..ededdb4 100644 --- a/flywms_navigation.py +++ b/flywms_navigation.py @@ -1,7 +1,9 @@ import argparse +import base64 import configparser import json import queue +import socket import sys import threading import time @@ -71,6 +73,7 @@ class Detection: class CandidateSnapshot: frame_id: int timestamp: float + capture_pose: dict[str, object] frame: np.ndarray bbox: tuple[int, int, int, int] label_bbox: tuple[int, int, int, int] @@ -137,6 +140,7 @@ class NavigationSnapshot: snapshot_id: int frame_id: int timestamp: float + capture_pose: dict[str, object] simulated_position: str track_id: int bbox: tuple[int, int, int, int] @@ -148,6 +152,16 @@ class NavigationSnapshot: movement_vector_px: tuple[float, float] +@dataclass(frozen=True) +class CapturedFrame: + frame_id: int + timestamp: float + video_time_sec: float + frame: np.ndarray + pose: dict[str, object] + read_ms: float + + @dataclass(frozen=True) class WmsSnapshotJob: request_id: str @@ -350,6 +364,124 @@ class WmsAsyncClient: ) +class ObserverPublisher: + def __init__(self, args): + self.enabled = bool(getattr(args, "observer_enabled", False)) + self.host = str(getattr(args, "observer_host", "127.0.0.1")) + self.port = int(getattr(args, "observer_port", 8765)) + self._queue: queue.Queue[dict[str, object] | None] = queue.Queue(maxsize=32) + self._stop = threading.Event() + self._thread: threading.Thread | None = None + if self.enabled: + self._thread = threading.Thread(target=self._worker, name="flywms-observer-publisher", daemon=True) + self._thread.start() + + def close(self) -> None: + if not self.enabled: + return + self._stop.set() + self._enqueue(None) + if self._thread is not None: + self._thread.join(timeout=2.0) + + def publish_telemetry(self, payload: dict[str, object]) -> None: + if not self.enabled: + return + self._enqueue({ + "type": "telemetry", + **payload, + }) + + def publish_preview( + self, + stream: str, + frame: np.ndarray, + frame_id: int, + timestamp: float, + jpeg_quality: int, + ) -> None: + if not self.enabled or frame is None: + return + ok, encoded = cv2.imencode( + ".jpg", + frame, + [int(cv2.IMWRITE_JPEG_QUALITY), int(max(20, min(100, jpeg_quality)))], + ) + if not ok: + return + self._enqueue({ + "type": "preview", + "stream": stream, + "frame_id": int(frame_id), + "timestamp": float(timestamp), + "width": int(frame.shape[1]), + "height": int(frame.shape[0]), + "encoding": "jpeg-base64", + "image_b64": base64.b64encode(encoded.tobytes()).decode("ascii"), + }) + + def _enqueue(self, payload: dict[str, object] | None) -> None: + try: + self._queue.put_nowait(payload) + return + except queue.Full: + try: + self._queue.get_nowait() + except queue.Empty: + pass + try: + self._queue.put_nowait(payload) + except queue.Full: + pass + + def _worker(self) -> None: + server: socket.socket | None = None + conn: socket.socket | None = None + try: + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind((self.host, self.port)) + server.listen(1) + server.settimeout(0.5) + log(f"[OBS] publisher in ascolto su {self.host}:{self.port}") + while not self._stop.is_set(): + if conn is None: + try: + conn, addr = server.accept() + conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + log(f"[OBS] observer connesso da {addr[0]}:{addr[1]}") + except socket.timeout: + continue + except OSError: + break + try: + payload = self._queue.get(timeout=0.2) + except queue.Empty: + continue + if payload is None: + break + try: + raw = (json.dumps(payload, ensure_ascii=True) + "\n").encode("utf-8") + conn.sendall(raw) + except OSError: + try: + conn.close() + except OSError: + pass + conn = None + finally: + if conn is not None: + try: + conn.close() + except OSError: + pass + if server is not None: + try: + server.close() + except OSError: + pass + + class LightweightTracker: """Greedy bbox tracker: enough to explain and test navigation decisions.""" @@ -431,12 +563,13 @@ class NavigationController: def process_track( self, track: Track, - frame: np.ndarray, - frame_id: int, - timestamp: float, + captured: CapturedFrame, labels: list[Detection] | None = None, ) -> NavigationSnapshot | None: labels = labels or [] + frame = captured.frame + frame_id = captured.frame_id + timestamp = captured.timestamp frame_h, frame_w = frame.shape[:2] eligible, score_parts = self._is_snapshot_candidate(track, frame_w, frame_h) self._update_track_state(track, eligible, frame_w) @@ -459,6 +592,7 @@ class NavigationController: candidate = CandidateSnapshot( frame_id=frame_id, timestamp=timestamp, + capture_pose=dict(captured.pose), frame=frame.copy(), bbox=track.bbox, label_bbox=label.bbox, @@ -623,6 +757,7 @@ class NavigationController: snapshot_id=self.snapshot_counter, frame_id=best.frame_id, timestamp=best.timestamp, + capture_pose=dict(best.capture_pose), simulated_position=simulated_position, track_id=track.id, bbox=best.bbox, @@ -725,10 +860,7 @@ class NavigationController: "frame_id": snapshot.frame_id, "timestamp": snapshot.timestamp, "simulated_position": snapshot.simulated_position, - "drone_pose_simulated": { - "mode": "linear_shelf_scan", - "position_label": snapshot.simulated_position, - }, + "drone_pose_simulated": dict(snapshot.capture_pose), "track_id": snapshot.track_id, "gaylord_bbox": list(snapshot.bbox), "label_bbox": list(snapshot.label_bbox), @@ -887,6 +1019,14 @@ def parse_args(): help="FPS massimo per lettura/preview realtime. 0 = FPS sorgente") ap.add_argument("--yolo-fps", type=float, default=defaults["yolo_fps"], help="FPS massimo per inferenza YOLO. 0 = ogni frame di preview") + ap.add_argument("--adaptive-yolo-enabled", action=argparse.BooleanOptionalAction, default=defaults["adaptive_yolo_enabled"], + help="Abilita scheduling adattivo di YOLO in base allo stato delle track") + ap.add_argument("--idle-yolo-fps", type=float, default=defaults["idle_yolo_fps"], + help="FPS YOLO quando non ci sono track attive") + ap.add_argument("--tracking-yolo-fps", type=float, default=defaults["tracking_yolo_fps"], + help="FPS YOLO quando ci sono track attive ma non ancora critiche") + ap.add_argument("--critical-yolo-fps", type=float, default=defaults["critical_yolo_fps"], + help="FPS YOLO quando una track e' candidate/centered") ap.add_argument("--max-frames", type=int, default=defaults["max_frames"], help="Numero massimo frame; 0 = tutto") ap.add_argument("--stats-interval", type=float, default=defaults["stats_interval"], help="Intervallo log prestazioni") ap.add_argument("--perf-log-path", default=defaults["perf_log_path"], help="File log tempi dettagliati") @@ -901,6 +1041,18 @@ def parse_args(): ap.add_argument("--debug-tracks", action="store_true", default=defaults["debug_tracks"], help="Logga stato e criteri delle track") ap.add_argument("--flash-alpha", type=float, default=defaults["flash_alpha"], help="Intensita' flash 0..1 al momento dello scatto") ap.add_argument("--no-display", action="store_true", default=defaults["no_display"], help="Disabilita finestra video") + ap.add_argument("--observer-enabled", action=argparse.BooleanOptionalAction, default=defaults["observer_enabled"], + help="Abilita observer esterno su socket locale; la UI locale viene disattivata") + ap.add_argument("--observer-host", default=defaults["observer_host"], help="Host observer, tipicamente 127.0.0.1") + ap.add_argument("--observer-port", type=int, default=defaults["observer_port"], help="Porta TCP observer") + ap.add_argument("--observer-preview-fps", type=float, default=defaults["observer_preview_fps"], + help="FPS massimi delle preview inviate all'observer") + ap.add_argument("--observer-preview-width", type=int, default=defaults["observer_preview_width"], + help="Larghezza massima delle preview inviate all'observer") + ap.add_argument("--observer-jpeg-quality", type=int, default=defaults["observer_jpeg_quality"], + help="Qualita' JPEG 20..100 per le preview inviate all'observer") + ap.add_argument("--observer-telemetry-fps", type=float, default=defaults["observer_telemetry_fps"], + help="Frequenza telemetria observer in Hz") ap.add_argument("--window-layout-enabled", action="store_true", default=defaults["window_layout_enabled"], help="Posiziona e ridimensiona le finestre OpenCV") ap.add_argument("--navigate-window", default=defaults["navigate_window"], help="Layout finestra navigate: x,y,w,h") @@ -935,7 +1087,7 @@ def load_navigation_config(path_str: str) -> dict[str, object]: "min_area_trend": -0.35, "snapshot_window_frames": 1, "snapshot_output_dir": "navigate_snapshots", - "remote_ack_timeout_sec": 2.0, + "remote_ack_timeout_sec": 3.0, "remote_ack_mode": "always-ack", "wms_enabled": False, "wms_server_url": "http://127.0.0.1:8088/api/v1/navigation-snapshot", @@ -953,6 +1105,10 @@ def load_navigation_config(path_str: str) -> dict[str, object]: "realtime_playback": True, "preview_fps": 24.0, "yolo_fps": 15.0, + "adaptive_yolo_enabled": False, + "idle_yolo_fps": 8.0, + "tracking_yolo_fps": 12.0, + "critical_yolo_fps": 15.0, "max_frames": 0, "stats_interval": 2.0, "perf_log_path": "tempistiche.txt", @@ -963,6 +1119,13 @@ def load_navigation_config(path_str: str) -> dict[str, object]: "debug_tracks": True, "flash_alpha": 0.70, "no_display": False, + "observer_enabled": False, + "observer_host": "127.0.0.1", + "observer_port": 8765, + "observer_preview_fps": 4.0, + "observer_preview_width": 960, + "observer_jpeg_quality": 75, + "observer_telemetry_fps": 8.0, "window_layout_enabled": True, "navigate_window": "20,40,1100,620", "commands_window": "1140,40,760,520", @@ -1010,6 +1173,35 @@ def format_fps_value(value: float | int | None) -> str: return f"{numeric:.1f}" +def format_seconds(value: float | None) -> str: + if value is None: + return "n/d" + try: + numeric = float(value) + except (TypeError, ValueError): + return "n/d" + if numeric < 0: + numeric = 0.0 + minutes = int(numeric // 60) + seconds = numeric - (minutes * 60) + return f"{minutes:02d}:{seconds:05.2f}" + + +def choose_adaptive_yolo_fps(args, tracks: list[Track]) -> tuple[float, str]: + if not getattr(args, "adaptive_yolo_enabled", False): + return float(args.yolo_fps or 0.0), "fixed" + + active_tracks = [t for t in tracks if t.missed == 0 and not t.already_snapshotted] + if not active_tracks: + return float(args.idle_yolo_fps or 0.0), "idle" + + critical_states = {"candidate", "centered"} + if any(t.state in critical_states for t in active_tracks): + return float(args.critical_yolo_fps or 0.0), "critical" + + return float(args.tracking_yolo_fps or 0.0), "tracking" + + class PerfLogWriter: def __init__(self, path_str: str, flush_interval_sec: float, flush_lines: int): self.path = Path(path_str) @@ -1039,6 +1231,117 @@ class PerfLogWriter: self.file.close() +def safe_progress_ratio(frame_id: int, total_frames: int) -> float: + if total_frames <= 0: + return 0.0 + return min(max((frame_id - 1) / max(total_frames - 1, 1), 0.0), 1.0) + + +def sample_demo_pose( + args, + frame_id: int, + video_time_sec: float, + total_frames: int, +) -> dict[str, object]: + return { + "mode": "video_demo_capture", + "frame_id": int(frame_id), + "video_time_sec": float(video_time_sec), + "scan_direction": str(args.scan_direction), + "route_progress_ratio": float(safe_progress_ratio(frame_id, total_frames)), + } + + +class CaptureWorker: + def __init__( + self, + cap: cv2.VideoCapture, + args, + video_fps: float, + frame_delay: float, + total_frames: int, + ) -> None: + self.cap = cap + self.args = args + self.video_fps = float(video_fps or 0.0) + self.frame_delay = float(frame_delay or 0.0) + self.total_frames = int(total_frames) + self.queue: queue.Queue[CapturedFrame | None] = queue.Queue(maxsize=1) + self.stop_event = threading.Event() + self.thread = threading.Thread(target=self._run, name="capture-worker", daemon=True) + self.last_emit = time.perf_counter() + + def start(self) -> None: + self.thread.start() + + def close(self) -> None: + self.stop_event.set() + self.thread.join(timeout=2.0) + + def get(self, timeout: float = 1.0) -> CapturedFrame | None: + deadline = time.perf_counter() + max(timeout, 0.1) + while True: + try: + return self.queue.get(timeout=0.1) + except queue.Empty: + if self.stop_event.is_set() and self.queue.empty(): + return None + if time.perf_counter() >= deadline: + raise + + def _offer(self, packet: CapturedFrame | None) -> None: + while not self.stop_event.is_set(): + try: + self.queue.put_nowait(packet) + return + except queue.Full: + try: + self.queue.get_nowait() + except queue.Empty: + return + + def _sleep_for_realtime(self) -> None: + if self.frame_delay <= 0: + return + now = time.perf_counter() + sleep_for = self.frame_delay - (now - self.last_emit) + if sleep_for > 0: + time.sleep(sleep_for) + self.last_emit = time.perf_counter() + + def _run(self) -> None: + frame_id = 0 + try: + while not self.stop_event.is_set(): + self._sleep_for_realtime() + read_t0 = time.perf_counter() + ok, frame = self.cap.read() + read_ms = (time.perf_counter() - read_t0) * 1000.0 + if not ok: + self._offer(None) + return + frame_id += 1 + timestamp = time.perf_counter() + pos_msec = float(self.cap.get(cv2.CAP_PROP_POS_MSEC) or 0.0) + if pos_msec > 0: + video_time_sec = pos_msec / 1000.0 + elif self.video_fps > 0: + video_time_sec = max(frame_id - 1, 0) / self.video_fps + else: + video_time_sec = 0.0 + pose = sample_demo_pose(self.args, frame_id, video_time_sec, self.total_frames) + self._offer(CapturedFrame( + frame_id=frame_id, + timestamp=timestamp, + video_time_sec=video_time_sec, + frame=frame, + pose=pose, + read_ms=read_ms, + )) + finally: + self.stop_event.set() + + def require_file(path_str: str, description: str) -> Path: path = Path(path_str) if not path.exists(): @@ -1324,6 +1627,50 @@ def draw_commands_window(command_lines: list[str], motion_text: str) -> np.ndarr return canvas +def build_observer_telemetry( + frame_id: int, + timestamp: float, + capture_pose: dict[str, object] | None, + processed_frames: int, + navigator: NavigationController, + tracks: list[Track], + gaylords: list[Detection], + labels: list[Detection], + run_yolo: bool, + last_yolo_ms: float, + yolo_cycles: int, + start_time: float, + video_fps: float, + preview_fps: float, + yolo_target_fps: float, + yolo_mode: str, +) -> dict[str, object]: + elapsed = max(time.perf_counter() - start_time, 0.001) + active = sum(1 for t in tracks if t.missed == 0) + return { + "frame_id": int(frame_id), + "processed_frames": int(processed_frames), + "timestamp": float(timestamp), + "capture_pose": dict(capture_pose or {}), + "command_text": navigator.last_command_text, + "command_lines": list(navigator.last_command_lines), + "motion_text": navigator.motion_text, + "snapshot_counter": int(navigator.snapshot_counter), + "det_count": int(len(gaylords)), + "label_count": int(len(labels)), + "track_count": int(len(tracks)), + "active_track_count": int(active), + "run_yolo": bool(run_yolo), + "yolo_mode": str(yolo_mode), + "last_yolo_ms": float(last_yolo_ms), + "loop_fps": float(processed_frames / elapsed), + "yolo_fps": float(yolo_cycles / elapsed), + "video_fps": float(video_fps), + "preview_fps": float(preview_fps), + "yolo_target_fps": float(yolo_target_fps), + } + + def command_arrow_mode(lines: list[str]) -> tuple[str, float, float] | None: for line in lines: if line.startswith("CENTRA_ETICHETTA"): @@ -1433,6 +1780,11 @@ def main() -> int: f"preview_fps={format_fps_value(args.preview_fps)} " f"log={args.perf_log_path}" ) + if args.observer_enabled: + if not args.no_display: + log("Observer esterno attivo: disabilito la UI locale integrata") + args.no_display = True + args.window_layout_enabled = False require_file(args.weights, "modello Ultralytics") detector = UltralyticsDetector(args.weights, args.ultralytics_device, args.yolo_half) @@ -1447,16 +1799,29 @@ def main() -> int: return 1 video_fps = cap.get(cv2.CAP_PROP_FPS) + total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0) + video_duration_sec = (total_frames / float(video_fps)) if video_fps and video_fps > 0 and total_frames > 0 else 0.0 preview_fps = args.preview_fps if args.preview_fps and args.preview_fps > 0 else video_fps if args.preview_fps and args.preview_fps > 0 and (args.video is None or str(args.video).isdigit()): cap.set(cv2.CAP_PROP_FPS, float(args.preview_fps)) frame_delay = 1.0 / preview_fps if args.realtime_playback and preview_fps and preview_fps > 1 else 0.0 - yolo_interval = 1.0 / args.yolo_fps if args.yolo_fps and args.yolo_fps > 0 else 0.0 log( f"FPS sorgente={format_fps_value(video_fps)} " f"preview_target={format_fps_value(preview_fps)} " f"yolo_target={format_fps_value(args.yolo_fps)}" ) + if args.adaptive_yolo_enabled: + log( + "YOLO adattivo:" + f" idle={format_fps_value(args.idle_yolo_fps)}" + f" tracking={format_fps_value(args.tracking_yolo_fps)}" + f" critical={format_fps_value(args.critical_yolo_fps)}" + ) + if video_duration_sec > 0: + log( + f"Durata nominale video={video_duration_sec:.2f}s " + f"({format_seconds(video_duration_sec)})" + ) perf_writer = PerfLogWriter( args.perf_log_path, args.perf_log_flush_interval_sec, @@ -1476,6 +1841,9 @@ def main() -> int: ) navigator = NavigationController(args) wms_client = WmsAsyncClient(args) + observer = ObserverPublisher(args) + capture_worker = CaptureWorker(cap, args, float(video_fps or 0.0), frame_delay, total_frames) + capture_worker.start() if not args.no_display: cv2.namedWindow("flywms navigate", cv2.WINDOW_NORMAL) @@ -1485,17 +1853,78 @@ def main() -> int: apply_window_layout(args) frame_id = 0 + processed_frames = 0 start_time = time.perf_counter() last_stats = start_time - last_loop_end = start_time yolo_total_ms = 0.0 yolo_cycles = 0 + total_wms_wait_ms = 0.0 next_yolo_time = start_time last_yolo_ms = 0.0 + current_yolo_mode = "fixed" + observer_preview_interval = 1.0 / args.observer_preview_fps if args.observer_preview_fps and args.observer_preview_fps > 0 else 0.0 + observer_telemetry_interval = 1.0 / args.observer_telemetry_fps if args.observer_telemetry_fps and args.observer_telemetry_fps > 0 else 0.0 + last_observer_preview = 0.0 + last_observer_telemetry = 0.0 gaylords: list[Detection] = [] labels: list[Detection] = [] tracks: list[Track] = [] + def publish_observer_state(captured: CapturedFrame, run_yolo_flag: bool, force: bool = False) -> None: + nonlocal last_observer_preview, last_observer_telemetry + if not args.observer_enabled: + return + now = time.perf_counter() + if force or observer_telemetry_interval <= 0 or now - last_observer_telemetry >= observer_telemetry_interval: + observer.publish_telemetry( + build_observer_telemetry( + frame_id=captured.frame_id, + timestamp=captured.timestamp, + capture_pose=captured.pose, + processed_frames=processed_frames, + navigator=navigator, + tracks=tracks, + gaylords=gaylords, + labels=labels, + run_yolo=run_yolo_flag, + last_yolo_ms=last_yolo_ms, + yolo_cycles=yolo_cycles, + start_time=start_time, + video_fps=float(video_fps or 0.0), + preview_fps=float(preview_fps or 0.0), + yolo_target_fps=float(args.yolo_fps or 0.0), + yolo_mode=current_yolo_mode, + ) + ) + last_observer_telemetry = now + if force or observer_preview_interval <= 0 or now - last_observer_preview >= observer_preview_interval: + preview_frame = draw_navigation_debug( + captured.frame, + tracks, + args, + labels, + navigator.label_movement_arrow, + ) + preview_frame = resize_preview(preview_frame, args.observer_preview_width) + observer.publish_preview("navigate", preview_frame, captured.frame_id, captured.timestamp, args.observer_jpeg_quality) + if navigator.last_ocr_payload_frame is not None: + observer.publish_preview( + "snapshot", + resize_preview(navigator.last_ocr_payload_frame, args.observer_preview_width), + captured.frame_id, + captured.timestamp, + args.observer_jpeg_quality, + ) + if navigator.last_label_payload_frame is not None: + observer.publish_preview( + "label", + resize_preview(navigator.last_label_payload_frame, args.observer_preview_width), + captured.frame_id, + captured.timestamp, + args.observer_jpeg_quality, + ) + last_observer_preview = now + try: while True: frame_loop_start = time.perf_counter() @@ -1505,26 +1934,25 @@ def main() -> int: ui_ms = 0.0 snapshot_pause_ms = 0.0 wms_wait_ms = 0.0 - if frame_delay > 0: - now = time.perf_counter() - sleep_for = frame_delay - (now - last_loop_end) - if sleep_for > 0: - time.sleep(sleep_for) - last_loop_end = time.perf_counter() - - read_t0 = time.perf_counter() - ok, frame = cap.read() - read_ms = (time.perf_counter() - read_t0) * 1000.0 - if not ok: + try: + captured = capture_worker.get(timeout=2.0) + except queue.Empty: + continue + if captured is None: log("Fine stream") break - frame_id += 1 - timestamp = time.perf_counter() + frame = captured.frame + frame_id = captured.frame_id + timestamp = captured.timestamp + read_ms = captured.read_ms + processed_frames += 1 if args.max_frames > 0 and frame_id > args.max_frames: log(f"Raggiunto --max-frames={args.max_frames}") break new_snapshots: list[NavigationSnapshot] = [] + current_yolo_fps, current_yolo_mode = choose_adaptive_yolo_fps(args, tracks) + yolo_interval = 1.0 / current_yolo_fps if current_yolo_fps and current_yolo_fps > 0 else 0.0 run_yolo = yolo_interval <= 0 or timestamp >= next_yolo_time if run_yolo: next_yolo_time = timestamp + yolo_interval @@ -1548,33 +1976,80 @@ def main() -> int: ) for track in tracks: if track.missed == 0: - snapshot = navigator.process_track(track, frame, frame_id, timestamp, labels) + snapshot = navigator.process_track(track, captured, labels) if snapshot is not None: new_snapshots.append(snapshot) track_ms = (time.perf_counter() - track_t0) * 1000.0 if args.no_display and new_snapshots: - sequence_sec = ( - args.label_move_sec - + args.label_stabilization_sec - + args.label_return_sec - ) - if sequence_sec > 0: - pause_t0 = time.perf_counter() - time.sleep(sequence_sec) - snapshot_pause_ms += (time.perf_counter() - pause_t0) * 1000.0 - for snapshot in new_snapshots: - if args.wms_enabled: - request_id = wms_client.submit(snapshot) - wait_t0 = time.perf_counter() - result = wms_client.wait_for_result(request_id, args.remote_ack_timeout_sec) - wms_wait_ms += (time.perf_counter() - wait_t0) * 1000.0 - navigator.apply_wms_result(result, snapshot) - else: - if args.remote_ack_timeout_sec > 0: + if args.observer_enabled: + for snapshot in new_snapshots: + navigator.set_label_sequence_phase(snapshot, "move") + publish_observer_state(captured, run_yolo, force=True) + if args.label_move_sec > 0: + pause_t0 = time.perf_counter() + time.sleep(args.label_move_sec) + snapshot_pause_ms += (time.perf_counter() - pause_t0) * 1000.0 + + navigator.set_label_sequence_phase(snapshot, "stabilize") + publish_observer_state(captured, run_yolo, force=True) + if args.label_stabilization_sec > 0: + pause_t0 = time.perf_counter() + time.sleep(args.label_stabilization_sec) + snapshot_pause_ms += (time.perf_counter() - pause_t0) * 1000.0 + + navigator.set_label_sequence_phase(snapshot, "capture") + publish_observer_state(captured, run_yolo, force=True) + pause_t0 = time.perf_counter() + time.sleep(0.5) + snapshot_pause_ms += (time.perf_counter() - pause_t0) * 1000.0 + + request_id = wms_client.submit(snapshot) if args.wms_enabled else None + + navigator.set_label_sequence_phase(snapshot, "return") + publish_observer_state(captured, run_yolo, force=True) + if args.label_return_sec > 0: + pause_t0 = time.perf_counter() + time.sleep(args.label_return_sec) + snapshot_pause_ms += (time.perf_counter() - pause_t0) * 1000.0 + + navigator.set_label_sequence_phase(snapshot, "wait_wms") + publish_observer_state(captured, run_yolo, force=True) + if args.wms_enabled: wait_t0 = time.perf_counter() - time.sleep(args.remote_ack_timeout_sec) + result = wms_client.wait_for_result(request_id, args.remote_ack_timeout_sec) wms_wait_ms += (time.perf_counter() - wait_t0) * 1000.0 - navigator.simulate_remote_response(snapshot) + navigator.apply_wms_result(result, snapshot) + else: + if args.remote_ack_timeout_sec > 0: + wait_t0 = time.perf_counter() + time.sleep(args.remote_ack_timeout_sec) + wms_wait_ms += (time.perf_counter() - wait_t0) * 1000.0 + navigator.simulate_remote_response(snapshot) + publish_observer_state(captured, run_yolo, force=True) + navigator.label_movement_arrow = None + else: + sequence_sec = ( + args.label_move_sec + + args.label_stabilization_sec + + args.label_return_sec + ) + if sequence_sec > 0: + pause_t0 = time.perf_counter() + time.sleep(sequence_sec) + snapshot_pause_ms += (time.perf_counter() - pause_t0) * 1000.0 + for snapshot in new_snapshots: + if args.wms_enabled: + request_id = wms_client.submit(snapshot) + wait_t0 = time.perf_counter() + result = wms_client.wait_for_result(request_id, args.remote_ack_timeout_sec) + wms_wait_ms += (time.perf_counter() - wait_t0) * 1000.0 + navigator.apply_wms_result(result, snapshot) + else: + if args.remote_ack_timeout_sec > 0: + wait_t0 = time.perf_counter() + time.sleep(args.remote_ack_timeout_sec) + wms_wait_ms += (time.perf_counter() - wait_t0) * 1000.0 + navigator.simulate_remote_response(snapshot) now = time.perf_counter() if now - last_stats >= args.stats_interval: @@ -1582,7 +2057,8 @@ def main() -> int: avg_yolo = yolo_total_ms / max(yolo_cycles, 1) active = sum(1 for t in tracks if t.missed == 0) log( - f"fps={frame_id / elapsed:.1f} yolo_fps={yolo_cycles / elapsed:.1f} " + f"fps={processed_frames / elapsed:.1f} yolo_fps={yolo_cycles / elapsed:.1f} " + f"yolo_mode={current_yolo_mode} " f"avg_yolo={avg_yolo:.1f}ms det={len(gaylords)} labels={len(labels)} " f"tracks={len(tracks)} active={active} " f"snapshots={navigator.snapshot_counter} {navigator.motion_text}" @@ -1599,6 +2075,9 @@ def main() -> int: ) last_stats = now + if args.observer_enabled: + publish_observer_state(captured, run_yolo, force=False) + if not args.no_display: draw_t0 = time.perf_counter() display = draw_navigation_debug( @@ -1763,23 +2242,48 @@ def main() -> int: loop_end = time.perf_counter() elapsed = max(loop_end - start_time, 0.001) loop_ms = (loop_end - frame_loop_start) * 1000.0 + total_wms_wait_ms += wms_wait_ms active = sum(1 for t in tracks if t.missed == 0) perf_writer.write( f"{time.strftime('%Y-%m-%d %H:%M:%S')}\t{frame_id}\t{int(run_yolo)}\t" f"{read_ms:.3f}\t{last_yolo_ms if run_yolo else 0.0:.3f}\t{track_ms:.3f}\t" f"{draw_ms:.3f}\t{ui_ms:.3f}\t{snapshot_pause_ms:.3f}\t{wms_wait_ms:.3f}\t" - f"{loop_ms:.3f}\t{frame_id / elapsed:.3f}\t{yolo_cycles / elapsed:.3f}\t" + f"{loop_ms:.3f}\t{processed_frames / elapsed:.3f}\t{yolo_cycles / elapsed:.3f}\t" f"{format_fps_value(video_fps)}\t{format_fps_value(preview_fps)}\t{format_fps_value(args.yolo_fps)}\t" f"{len(gaylords)}\t{len(labels)}\t{len(tracks)}\t{active}\t{navigator.snapshot_counter}\t" f"{json.dumps(navigator.last_command_text, ensure_ascii=True)}" ) finally: + capture_worker.close() cap.release() wms_client.close() + observer.close() perf_writer.close() if not args.no_display: cv2.destroyAllWindows() + total_demo_sec = max(time.perf_counter() - start_time, 0.0) + net_demo_sec = max(total_demo_sec - (total_wms_wait_ms / 1000.0), 0.0) + if video_duration_sec > 0: + delta_total_sec = total_demo_sec - video_duration_sec + delta_net_sec = net_demo_sec - video_duration_sec + ratio_total_pct = ((total_demo_sec / video_duration_sec) - 1.0) * 100.0 + ratio_net_pct = ((net_demo_sec / video_duration_sec) - 1.0) * 100.0 + log( + "Durata demo:" + f" totale={total_demo_sec:.2f}s ({format_seconds(total_demo_sec)})" + f" netto_senza_wms={net_demo_sec:.2f}s ({format_seconds(net_demo_sec)})" + ) + log( + "Confronto video/demo:" + f" video={video_duration_sec:.2f}s ({format_seconds(video_duration_sec)})" + f" delta_totale={delta_total_sec:+.2f}s ({ratio_total_pct:+.1f}%)" + f" delta_netto_senza_wms={delta_net_sec:+.2f}s ({ratio_net_pct:+.1f}%)" + ) + log( + f"Attesa WMS totale={total_wms_wait_ms / 1000.0:.2f}s " + f"({format_seconds(total_wms_wait_ms / 1000.0)})" + ) log(f"Snapshot salvati in: {Path(args.snapshot_output_dir).resolve()}") return 0 diff --git a/flywms_navigation_observer.py b/flywms_navigation_observer.py new file mode 100644 index 0000000..ba07329 --- /dev/null +++ b/flywms_navigation_observer.py @@ -0,0 +1,114 @@ +import argparse +import base64 +import json +import socket +import time + +import cv2 +import numpy as np + +import flywms_navigation as nav + + +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") + 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 main() -> int: + args = parse_args() + 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) + 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) + 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 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/step1_core_observer_design.md b/step1_core_observer_design.md new file mode 100644 index 0000000..9c71e54 --- /dev/null +++ b/step1_core_observer_design.md @@ -0,0 +1,261 @@ +# FlyWMS Step 1 - Core/Observer Split + +Data: 2026-05-29 + +## Scopo + +Ridurre il tempo totale della demo fino a portarlo vicino alla durata reale del video, evitando che la parte visuale OpenCV rallenti il loop critico di acquisizione, inferenza e logica demo. + +Lo Step 1 non cambia ancora la struttura single-process del core di calcolo, ma separa in modo netto: + +- `core`: acquisizione, YOLO, tracking, macchina a stati, logging prestazioni +- `observer`: processo separato per preview e telemetria + +L'obiettivo e' ottenere una demo visibile senza riportare nel loop principale il costo della vecchia UI OpenCV. + +## Motivazione tecnica + +I benchmark gia' disponibili dicono: + +- video reale: `658.3 s` +- benchmark headless: `762.13 s` +- demo vecchia con UI integrata: molto piu' lenta + +La UI OpenCV dentro al loop ha gia' dimostrato di pesare in modo determinante. La strada piu' pulita e' quindi: + +1. mantenere il core il piu' simile possibile al benchmark +2. spostare la visualizzazione in un processo esterno +3. accettare che l'observer non mostri tutti i frame + +## Perimetro di Step 1 + +Step 1 include: + +- canale di telemetria `core -> observer` +- canale preview `core -> observer` +- observer separato e opzionale +- modalita' `latest update wins` + +Step 1 non include ancora: + +- multiprocessing tra capture e inference +- shared memory +- zero-copy +- nuovo frontend DearPyGUI +- protocollo distribuito multi-host + +## Architettura proposta + +### Processo core + +Responsabilita': + +- legge il video o la camera +- rispetta i limiti `preview_fps` e `yolo_fps` +- esegue YOLO +- aggiorna tracking e macchina a stati +- salva snapshot e gestisce WMS +- scrive `tempistiche.txt` +- pubblica telemetria e preview in modo non bloccante + +Il core non deve mai aspettare l'observer. + +### Processo observer + +Responsabilita': + +- ricevere dati dal core +- mantenere l'ultimo stato disponibile +- mostrare: + - preview principale + - eventuali preview secondarie + - comandi/stato + - fps/logica macchina a stati + +L'observer puo' perdere aggiornamenti. Questo e' accettabile. + +## Trasporto dati + +Per Step 1 si usa un trasporto semplice su `localhost`, in modo da ridurre il rischio di complessita' prematura. + +Scelta: + +- socket TCP locale per preview e telemetria + +Motivi: + +- semplice da debuggare +- leggibile anche con strumenti esterni +- facile da riusare in seguito +- non richiede introdurre subito shared memory + +## Formato messaggi + +### Telemetria + +Formato: JSON line-delimited o JSON framed su socket + +Campi minimi previsti: + +```json +{ + "type": "telemetry", + "frame_id": 123, + "timestamp": 1710000000.123, + "state": "scan_level", + "command_lines": ["...", "..."], + "motion_text": "MOTO: stabile ...", + "snapshot_counter": 4, + "det_count": 2, + "label_count": 1, + "track_count": 2, + "active_track_count": 2, + "run_yolo": true, + "last_yolo_ms": 28.4, + "loop_fps": 25.8, + "yolo_fps": 12.6, + "video_fps": 30.0, + "preview_fps": 30.0, + "yolo_target_fps": 15.0 +} +``` + +### Preview + +Formato: messaggio con header JSON + payload JPEG. + +Campi minimi header: + +```json +{ + "type": "preview", + "stream": "navigate", + "frame_id": 123, + "timestamp": 1710000000.123, + "width": 960, + "height": 540, + "encoding": "jpeg", + "jpeg_size": 45231 +} +``` + +Il payload binario segue subito dopo l'header. + +## Frequenze observer + +Per non pesare sul core: + +- telemetria: fino a `5-10 Hz` +- preview principale: `3-5 fps` +- preview snapshot/etichetta: solo su evento o ultima disponibile + +Step 1 non ha l'obiettivo di mostrare una preview fluida. Ha l'obiettivo di restare credibile a livello demo senza penalizzare il core. + +## Strategia "latest update wins" + +Il core non accumula backlog per l'observer. + +Regole: + +- se l'observer e' lento, gli aggiornamenti vecchi possono essere scartati +- il core invia solo l'ultimo stato rilevante +- nessuna coda lunga per la preview + +Questo vale in particolare per: + +- preview `navigate` +- testo comandi +- metriche fps + +## Gestione overlay + +Step 1 adotta una soluzione intermedia: + +- il core genera una preview gia' ridotta +- il core puo' continuare a disegnare l'overlay minimo necessario sulla preview inviata +- l'observer resta semplice e mostra il risultato + +Questo non e' il modello finale ideale, ma riduce il lavoro di riscrittura nello Step 1. + +Evoluzione futura possibile: + +- inviare frame piu' bbox/stato +- disegnare overlay interamente nell'observer + +## Modalita' di fallback + +Il sistema deve funzionare anche senza observer. + +Regole: + +- se l'observer non e' avviato, il core continua a lavorare +- se il socket non e' disponibile, il core logga e prosegue +- se l'observer si scollega, il core non deve bloccarsi + +Questo e' un requisito fondamentale dello Step 1. + +## Parametri di configurazione da introdurre + +Nuovi parametri suggeriti nel file INI: + +- `observer_enabled = true/false` +- `observer_host = 127.0.0.1` +- `observer_port = 8765` +- `observer_preview_fps = 4.0` +- `observer_preview_width = 960` +- `observer_jpeg_quality = 75` +- `observer_telemetry_fps = 8.0` + +## File previsti + +### Nuovo file + +- `flywms_navigation_observer.py` + +Responsabilita': + +- connettersi al core +- ricevere messaggi +- mantenere ultimo stato per stream +- mostrare finestre OpenCV lato observer + +### Modifiche a file esistente + +- `flywms_navigation.py` + - aggiunta configurazione observer + - serializzazione telemetria + - invio preview/telemetria non bloccante + - disattivazione progressiva della vecchia UI integrata + +## Piano implementativo di Step 1 + +1. aggiungere parametri observer a config e argparse +2. creare un piccolo publisher non bloccante nel core +3. creare `flywms_navigation_observer.py` +4. pubblicare: + - preview `navigate` + - preview `snapshot` + - preview `etichetta` + - telemetria/comandi +5. rendere opzionale la vecchia UI integrata +6. testare: + - core senza observer + - core con observer + - benchmark con observer attivo ma leggero + +## Criteri di successo + +Step 1 e' considerato riuscito se: + +1. il core continua a funzionare senza finestre locali +2. l'observer mostra una preview leggibile e lo stato della missione +3. l'observer non blocca il core se rallenta o si chiude +4. il tempo totale della demo si avvicina al benchmark headless, senza ricadere nella penalizzazione della vecchia UI + +## Limiti noti + +- Step 1 non risolve ancora il costo seriale di `cap.read()` e inferenza +- Step 1 non introduce ancora il multiprocessing tra capture e inference +- Step 1 non ottimizza ancora l'uso adattivo di YOLO + +Questi temi restano demandati agli Step 2 e 3. diff --git a/step2_capture_inference_design.md b/step2_capture_inference_design.md new file mode 100644 index 0000000..6a0778f --- /dev/null +++ b/step2_capture_inference_design.md @@ -0,0 +1,104 @@ +# Step 2 - Disaccoppiamento Capture / Inference + +## Obiettivo + +Ridurre la serializzazione del loop principale separando: + +- acquisizione video e campionamento metadati; +- inferenza, tracking e logica demo. + +Lo scopo non e' introdurre ancora una pipeline completa multi-processo stile ROS, ma togliere dal percorso critico almeno la parte `cap.read()` e la preparazione del pacchetto frame. + +## Vincolo architetturale + +Il frame consumato dall'inferenza non puo' essere un'immagine grezza isolata. + +Ogni frame deve essere rappresentato da un pacchetto logico di acquisizione che congela nello stesso istante: + +- `frame_id` +- `timestamp` +- `video_time_sec` +- immagine +- posa / stato del drone campionati nel momento di acquisizione + +Questo segue il modello gia' fissato nei documenti di architettura: + +- `capture` non inventa la posa; +- `capture` la campiona dalla sorgente di stato disponibile; +- `inference` consuma un `CapturedFrame` gia' coerente. + +Nel contesto della demo video attuale non esiste ancora una posa SLAM reale. Quindi il campo posa verra' popolato con una posa simulata minima, ma congelata all'acquisizione, utile a: + +- mantenere il contratto architetturale corretto; +- evitare che in futuro frame e stato si disallineino; +- propagare i metadati fino agli snapshot. + +## Struttura dati proposta + +```text +CapturedFrame +- frame_id +- timestamp +- video_time_sec +- frame +- pose +``` + +Dove `pose` e' un dizionario o struttura equivalente con almeno: + +- `mode` +- `frame_id` +- `video_time_sec` +- `scan_direction` +- `route_progress_ratio` + +Il campo `pose` potra' essere sostituito piu' avanti da un provider reale SLAM / PX4 / odometria senza cambiare il contratto tra capture e inference. + +## Strategia di esecuzione + +### Capture worker + +Un worker separato: + +- legge da `VideoCapture` +- costruisce `CapturedFrame` +- pubblica solo l'ultimo frame disponibile + +### Inference / control loop + +Il loop principale: + +- legge l'ultimo `CapturedFrame` +- esegue YOLO, tracking e logica demo +- usa sempre i metadati congelati nel pacchetto + +## Regola di coda + +La coda deve essere piccola: + +- niente backlog lungo; +- meglio perdere frame che accumulare ritardo; +- politica `latest frame wins`. + +## Impatto sul codice esistente + +### Nuove entita' + +- `CapturedFrame` +- `CaptureWorker` +- helper `sample_demo_pose(...)` + +### Oggetti da aggiornare + +- `CandidateSnapshot` +- `NavigationSnapshot` +- metadata snapshot JSON +- observer telemetry, se utile, con riferimento alla posa dell'ultimo frame + +## Criteri di successo + +1. il core continua a produrre lo stesso comportamento funzionale della demo; +2. inferenza e snapshot lavorano su frame con metadati coerenti; +3. `cap.read()` non sta piu' nel loop principale critico; +4. nessun backlog crescente; +5. nessun blocco se il capture worker arriva piu' veloce o piu' lento del consumer. diff --git a/step3_adaptive_inference_design.md b/step3_adaptive_inference_design.md new file mode 100644 index 0000000..315c6f3 --- /dev/null +++ b/step3_adaptive_inference_design.md @@ -0,0 +1,55 @@ +# Step 3 - Inferenza adattiva + +## Obiettivo + +Ridurre il tempo totale della demo senza cambiare modello o logica di missione, variando la frequenza di YOLO in base al contesto invece di usare un solo valore fisso. + +## Principio + +L'inferenza non deve avere sempre lo stesso ritmo. + +Ci sono tre situazioni principali: + +1. **scansione tranquilla** + - nessuna track attiva + - nessun target interessante vicino alla zona utile + - YOLO puo' girare piu' lentamente + +2. **tracciamento normale** + - una o piu' track attive + - target presenti ma non ancora in fase critica + - YOLO gira a frequenza intermedia + +3. **fase critica / near snapshot** + - track in stato `candidate` o `centered` + - target vicino alla linea di snapshot + - YOLO gira a frequenza alta + +## Regole iniziali + +Versione conservativa: + +- nessuna track attiva -> `idle_yolo_fps` +- track attive ma nessuna `candidate/centered` -> `tracking_yolo_fps` +- almeno una `candidate/centered` -> `critical_yolo_fps` + +Queste regole usano lo stato gia' prodotto dal tracker e dal `NavigationController`, quindi non introducono nuova logica semantica invasiva. + +## Vincoli + +- la frequenza adattiva non deve scendere troppo, altrimenti si rischia di ritardare il riaggancio del target; +- la frequenza critica non deve superare in modo aggressivo la capacita' reale della macchina; +- il comportamento deve restare deterministico e leggibile nei log. + +## Configurazione proposta + +- `adaptive_yolo_enabled` +- `idle_yolo_fps` +- `tracking_yolo_fps` +- `critical_yolo_fps` + +## Criterio di successo + +1. ridurre la durata netta della demo; +2. non peggiorare visibilmente il comportamento di tracking/snapshot; +3. mantenere il codice semplice e facilmente spegnibile.