External observer with OpenCV UI baseline

This commit is contained in:
administrator
2026-06-03 15:28:27 +02:00
parent f728524ee6
commit e86c05a885
9 changed files with 1375 additions and 47 deletions

View File

@@ -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
```

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.