2293 lines
94 KiB
Python
2293 lines
94 KiB
Python
import argparse
|
|
import base64
|
|
import configparser
|
|
import json
|
|
import queue
|
|
import socket
|
|
import sys
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from urllib import request, error
|
|
|
|
import cv2
|
|
import numpy as np
|
|
|
|
|
|
DEFAULT_CONFIG_PATH = "flywms_navigation.ini"
|
|
CUDA_MIN_PIXELS = 640 * 360
|
|
|
|
|
|
def opencv_cuda_available() -> bool:
|
|
try:
|
|
return hasattr(cv2, "cuda") and cv2.cuda.getCudaEnabledDeviceCount() > 0
|
|
except cv2.error:
|
|
return False
|
|
|
|
|
|
OPENCV_CUDA_AVAILABLE = opencv_cuda_available()
|
|
|
|
|
|
def cuda_resize(
|
|
image: np.ndarray,
|
|
size: tuple[int, int],
|
|
interpolation: int = cv2.INTER_LINEAR,
|
|
min_pixels: int = CUDA_MIN_PIXELS,
|
|
) -> np.ndarray:
|
|
if not OPENCV_CUDA_AVAILABLE or image.size < min_pixels:
|
|
return cv2.resize(image, size, interpolation=interpolation)
|
|
try:
|
|
gpu = cv2.cuda_GpuMat()
|
|
gpu.upload(image)
|
|
return cv2.cuda.resize(gpu, size, interpolation=interpolation).download()
|
|
except cv2.error:
|
|
return cv2.resize(image, size, interpolation=interpolation)
|
|
|
|
|
|
def cuda_cvt_color(
|
|
image: np.ndarray,
|
|
code: int,
|
|
min_pixels: int = CUDA_MIN_PIXELS,
|
|
) -> np.ndarray:
|
|
if not OPENCV_CUDA_AVAILABLE or image.size < min_pixels:
|
|
return cv2.cvtColor(image, code)
|
|
try:
|
|
gpu = cv2.cuda_GpuMat()
|
|
gpu.upload(image)
|
|
return cv2.cuda.cvtColor(gpu, code).download()
|
|
except cv2.error:
|
|
return cv2.cvtColor(image, code)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Detection:
|
|
class_id: int
|
|
class_name: str
|
|
confidence: float
|
|
bbox: tuple[int, int, int, int]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
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]
|
|
label_confidence: float
|
|
score: float
|
|
center_score: float
|
|
size_score: float
|
|
cut_score: float
|
|
|
|
|
|
@dataclass
|
|
class Track:
|
|
id: int
|
|
bbox: tuple[int, int, int, int]
|
|
confidence: float
|
|
first_seen_frame: int
|
|
last_seen_frame: int
|
|
hits: int = 1
|
|
missed: int = 0
|
|
state: str = "entering"
|
|
last_candidate_reason: str = ""
|
|
pending_remote_response: str = "none"
|
|
already_snapshotted: bool = False
|
|
bbox_history: list[tuple[int, int, int, int]] = field(default_factory=list)
|
|
center_history: list[tuple[float, float]] = field(default_factory=list)
|
|
area_history: list[float] = field(default_factory=list)
|
|
candidates: list[CandidateSnapshot] = field(default_factory=list)
|
|
|
|
def __post_init__(self) -> None:
|
|
self._append_history(self.bbox)
|
|
|
|
def update(self, bbox: tuple[int, int, int, int], confidence: float, frame_id: int) -> None:
|
|
self.bbox = bbox
|
|
self.confidence = confidence
|
|
self.last_seen_frame = frame_id
|
|
self.hits += 1
|
|
self.missed = 0
|
|
self._append_history(bbox)
|
|
|
|
def mark_missed(self) -> None:
|
|
self.missed += 1
|
|
if self.missed > 0 and self.state != "snapshotted":
|
|
self.state = "exiting"
|
|
|
|
def _append_history(self, bbox: tuple[int, int, int, int]) -> None:
|
|
self.bbox_history.append(bbox)
|
|
self.center_history.append(bbox_center(bbox))
|
|
self.area_history.append(float(bbox_area(bbox)))
|
|
keep = 20
|
|
self.bbox_history = self.bbox_history[-keep:]
|
|
self.center_history = self.center_history[-keep:]
|
|
self.area_history = self.area_history[-keep:]
|
|
|
|
def area_trend(self) -> float:
|
|
if len(self.area_history) < 4:
|
|
return 0.0
|
|
old = self.area_history[-4]
|
|
new = self.area_history[-1]
|
|
return (new - old) / max(old, 1.0)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
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]
|
|
label_bbox: tuple[int, int, int, int]
|
|
score: float
|
|
debug_frame_path: str
|
|
ocr_payload_path: str
|
|
label_payload_path: str
|
|
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
|
|
snapshot: NavigationSnapshot
|
|
metadata: dict[str, object]
|
|
label_image_path: str
|
|
gaylord_image_path: str | None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class WmsResult:
|
|
request_id: str
|
|
snapshot_id: int
|
|
status: str
|
|
message: str
|
|
response: dict[str, object] | None = None
|
|
error: str = ""
|
|
|
|
|
|
class UltralyticsDetector:
|
|
def __init__(self, model_path: str, device: str, half: bool):
|
|
from ultralytics import YOLO
|
|
|
|
self.model = YOLO(model_path)
|
|
self.device = device
|
|
self.half = bool(half) and str(device).strip().lower() != "cpu"
|
|
names = self.model.names
|
|
if isinstance(names, dict):
|
|
self.classes = [str(names[i]) for i in sorted(names)]
|
|
else:
|
|
self.classes = [str(name) for name in names]
|
|
|
|
def detect(
|
|
self,
|
|
frame: np.ndarray,
|
|
min_confidence: float,
|
|
input_size: int,
|
|
) -> tuple[list[Detection], float]:
|
|
t0 = time.perf_counter()
|
|
results = self.model.predict(
|
|
source=frame,
|
|
imgsz=input_size,
|
|
conf=min_confidence,
|
|
device=self.device,
|
|
half=self.half,
|
|
verbose=False,
|
|
)
|
|
elapsed_ms = (time.perf_counter() - t0) * 1000.0
|
|
|
|
detections: list[Detection] = []
|
|
if not results:
|
|
return detections, elapsed_ms
|
|
|
|
boxes = results[0].boxes
|
|
if boxes is None:
|
|
return detections, elapsed_ms
|
|
|
|
xyxy = boxes.xyxy.cpu().numpy()
|
|
confs = boxes.conf.cpu().numpy()
|
|
clss = boxes.cls.cpu().numpy().astype(int)
|
|
for box, conf, cls_id in zip(xyxy, confs, clss):
|
|
x1, y1, x2, y2 = [int(round(v)) for v in box]
|
|
x1, y1, x2, y2 = clip_box(x1, y1, x2, y2, frame.shape[1], frame.shape[0])
|
|
if x2 <= x1 or y2 <= y1:
|
|
continue
|
|
class_name = self.classes[cls_id] if 0 <= cls_id < len(self.classes) else str(cls_id)
|
|
detections.append(Detection(
|
|
class_id=int(cls_id),
|
|
class_name=class_name,
|
|
confidence=float(conf),
|
|
bbox=(x1, y1, x2, y2),
|
|
))
|
|
return detections, elapsed_ms
|
|
|
|
|
|
class WmsAsyncClient:
|
|
def __init__(self, args):
|
|
self.args = args
|
|
self.enabled = bool(args.wms_enabled)
|
|
self.jobs: queue.Queue[WmsSnapshotJob | None] = queue.Queue(maxsize=args.wms_queue_max_size)
|
|
self.results: queue.Queue[WmsResult] = queue.Queue()
|
|
self._stop = threading.Event()
|
|
self._thread: threading.Thread | None = None
|
|
if self.enabled:
|
|
self._thread = threading.Thread(target=self._worker, name="flywms-wms-client", daemon=True)
|
|
self._thread.start()
|
|
|
|
def close(self) -> None:
|
|
if not self.enabled:
|
|
return
|
|
self._stop.set()
|
|
try:
|
|
self.jobs.put_nowait(None)
|
|
except queue.Full:
|
|
pass
|
|
if self._thread is not None:
|
|
self._thread.join(timeout=2.0)
|
|
|
|
def submit(self, snapshot: NavigationSnapshot) -> str | None:
|
|
if not self.enabled:
|
|
return None
|
|
request_id = str(uuid.uuid4())
|
|
metadata = {
|
|
"request_id": request_id,
|
|
"client_id": self.args.wms_client_id,
|
|
"snapshot_id": snapshot.snapshot_id,
|
|
"frame_id": snapshot.frame_id,
|
|
"timestamp": snapshot.timestamp,
|
|
"simulated_position": snapshot.simulated_position,
|
|
"track_id": snapshot.track_id,
|
|
"gaylord_bbox": list(snapshot.bbox),
|
|
"label_bbox": list(snapshot.label_bbox),
|
|
"movement_vector_px": {
|
|
"dx": snapshot.movement_vector_px[0],
|
|
"dy": snapshot.movement_vector_px[1],
|
|
},
|
|
}
|
|
job = WmsSnapshotJob(
|
|
request_id=request_id,
|
|
snapshot=snapshot,
|
|
metadata=metadata,
|
|
label_image_path=snapshot.label_payload_path,
|
|
gaylord_image_path=snapshot.debug_frame_path if self.args.wms_send_gaylord_debug else None,
|
|
)
|
|
try:
|
|
self.jobs.put_nowait(job)
|
|
except queue.Full:
|
|
self.results.put(WmsResult(
|
|
request_id=request_id,
|
|
snapshot_id=snapshot.snapshot_id,
|
|
status="ERROR",
|
|
message="WMS_QUEUE_FULL",
|
|
error="queue full",
|
|
))
|
|
return request_id
|
|
log(f"[WMS] job accodato request_id={request_id} snapshot={snapshot.snapshot_id}")
|
|
return request_id
|
|
|
|
def poll_results(self) -> list[WmsResult]:
|
|
results: list[WmsResult] = []
|
|
while True:
|
|
try:
|
|
results.append(self.results.get_nowait())
|
|
except queue.Empty:
|
|
return results
|
|
|
|
def wait_for_result(self, request_id: str | None, timeout_sec: float) -> WmsResult | None:
|
|
if not request_id:
|
|
return None
|
|
deadline = time.perf_counter() + max(0.0, timeout_sec)
|
|
deferred: list[WmsResult] = []
|
|
try:
|
|
while time.perf_counter() <= deadline:
|
|
try:
|
|
result = self.results.get(timeout=0.05)
|
|
except queue.Empty:
|
|
continue
|
|
if result.request_id == request_id:
|
|
return result
|
|
deferred.append(result)
|
|
return None
|
|
finally:
|
|
for result in deferred:
|
|
self.results.put(result)
|
|
|
|
def _worker(self) -> None:
|
|
while not self._stop.is_set():
|
|
try:
|
|
job = self.jobs.get(timeout=0.2)
|
|
except queue.Empty:
|
|
continue
|
|
if job is None:
|
|
break
|
|
result = self._send_job(job)
|
|
self.results.put(result)
|
|
|
|
def _send_job(self, job: WmsSnapshotJob) -> WmsResult:
|
|
try:
|
|
body, content_type = build_wms_multipart(job)
|
|
req = request.Request(
|
|
self.args.wms_server_url,
|
|
data=body,
|
|
headers={"Content-Type": content_type},
|
|
method="POST",
|
|
)
|
|
with request.urlopen(req, timeout=self.args.wms_timeout_sec) as response:
|
|
payload = json.loads(response.read().decode("utf-8"))
|
|
status = str(payload.get("status", "ERROR"))
|
|
message = str(payload.get("message", ""))
|
|
log(f"[WMS] risposta request_id={job.request_id} status={status} message={message}")
|
|
return WmsResult(job.request_id, job.snapshot.snapshot_id, status, message, payload)
|
|
except (OSError, error.URLError, TimeoutError, json.JSONDecodeError) as exc:
|
|
log(f"[WMS] errore request_id={job.request_id}: {exc}")
|
|
return WmsResult(
|
|
request_id=job.request_id,
|
|
snapshot_id=job.snapshot.snapshot_id,
|
|
status="ERROR",
|
|
message="WMS_SEND_ERROR",
|
|
error=str(exc),
|
|
)
|
|
|
|
|
|
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."""
|
|
|
|
def __init__(
|
|
self,
|
|
max_missed: int,
|
|
min_match_score: float,
|
|
max_center_distance_ratio: float,
|
|
):
|
|
self.max_missed = max_missed
|
|
self.min_match_score = min_match_score
|
|
self.max_center_distance_ratio = max_center_distance_ratio
|
|
self._next_id = 1
|
|
self.tracks: dict[int, Track] = {}
|
|
|
|
def update(
|
|
self,
|
|
detections: list[Detection],
|
|
frame_id: int,
|
|
frame_width: int,
|
|
) -> list[Track]:
|
|
unmatched_tracks = set(self.tracks.keys())
|
|
unmatched_detections = set(range(len(detections)))
|
|
pairs: list[tuple[float, int, int]] = []
|
|
|
|
max_center_distance = max(1.0, frame_width * self.max_center_distance_ratio)
|
|
for track_id, track in self.tracks.items():
|
|
for det_idx, det in enumerate(detections):
|
|
score = association_score(track.bbox, det.bbox, max_center_distance)
|
|
if score >= self.min_match_score:
|
|
pairs.append((score, track_id, det_idx))
|
|
|
|
pairs.sort(reverse=True, key=lambda item: item[0])
|
|
for _, track_id, det_idx in pairs:
|
|
if track_id not in unmatched_tracks or det_idx not in unmatched_detections:
|
|
continue
|
|
det = detections[det_idx]
|
|
self.tracks[track_id].update(det.bbox, det.confidence, frame_id)
|
|
unmatched_tracks.remove(track_id)
|
|
unmatched_detections.remove(det_idx)
|
|
|
|
for track_id in list(unmatched_tracks):
|
|
self.tracks[track_id].mark_missed()
|
|
if self.tracks[track_id].missed > self.max_missed:
|
|
del self.tracks[track_id]
|
|
|
|
for det_idx in unmatched_detections:
|
|
det = detections[det_idx]
|
|
track_id = self._next_id
|
|
self._next_id += 1
|
|
self.tracks[track_id] = Track(
|
|
id=track_id,
|
|
bbox=det.bbox,
|
|
confidence=det.confidence,
|
|
first_seen_frame=frame_id,
|
|
last_seen_frame=frame_id,
|
|
)
|
|
|
|
return list(self.tracks.values())
|
|
|
|
|
|
class NavigationController:
|
|
def __init__(self, args):
|
|
self.args = args
|
|
self.output_dir = Path(args.snapshot_output_dir)
|
|
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
self.metadata_path = self.output_dir / "snapshots.jsonl"
|
|
self.snapshot_counter = 0
|
|
self.position_counter = 0
|
|
self.last_command_text = ""
|
|
self.last_command_lines: list[str] = []
|
|
self.last_snapshot_frame: np.ndarray | None = None
|
|
self.last_ocr_payload_frame: np.ndarray | None = None
|
|
self.last_label_payload_frame: np.ndarray | None = None
|
|
self.last_remote_result_text = ""
|
|
self.motion_text = "MOTO: n/d"
|
|
self.label_movement_arrow: tuple[tuple[int, int], tuple[int, int]] | None = None
|
|
|
|
def process_track(
|
|
self,
|
|
track: Track,
|
|
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)
|
|
|
|
if track.already_snapshotted:
|
|
return None
|
|
|
|
if eligible:
|
|
label = find_label_inside_bbox(track.bbox, labels)
|
|
if label is None:
|
|
track.last_candidate_reason = "label_missing"
|
|
self.last_command_text = f"ETICHETTA_NON_TROVATA track={track.id}"
|
|
self.last_command_lines = [
|
|
self.last_command_text,
|
|
"MICRO_RICERCA_ETICHETTA",
|
|
"ATTENDI_FRAME_SUCCESSIVO",
|
|
]
|
|
log(f"[NAV] ETICHETTA_NON_TROVATA track={track.id}: attendo frame successivo")
|
|
return None
|
|
candidate = CandidateSnapshot(
|
|
frame_id=frame_id,
|
|
timestamp=timestamp,
|
|
capture_pose=dict(captured.pose),
|
|
frame=frame.copy(),
|
|
bbox=track.bbox,
|
|
label_bbox=label.bbox,
|
|
label_confidence=label.confidence,
|
|
score=score_parts["score"],
|
|
center_score=score_parts["center_score"],
|
|
size_score=score_parts["size_score"],
|
|
cut_score=score_parts["cut_score"],
|
|
)
|
|
track.candidates.append(candidate)
|
|
track.candidates = track.candidates[-self.args.snapshot_window_frames:]
|
|
|
|
if len(track.candidates) >= self.args.snapshot_window_frames:
|
|
return self._finalize_snapshot(track)
|
|
elif track.candidates:
|
|
return self._finalize_snapshot(track)
|
|
|
|
return None
|
|
|
|
def _is_snapshot_candidate(
|
|
self,
|
|
track: Track,
|
|
frame_w: int,
|
|
frame_h: int,
|
|
) -> tuple[bool, dict[str, float]]:
|
|
x1, y1, x2, y2 = track.bbox
|
|
cx, cy = bbox_center(track.bbox)
|
|
center_x = frame_w * 0.5
|
|
center_tolerance = max(1.0, frame_w * self.args.center_tolerance_ratio)
|
|
snapshot_tolerance = max(1.0, frame_w * self.args.snapshot_line_tolerance_ratio)
|
|
center_delta = abs(cx - center_x)
|
|
center_score = max(0.0, 1.0 - center_delta / center_tolerance)
|
|
|
|
area_ratio = bbox_area(track.bbox) / float(frame_w * frame_h)
|
|
size_score = min(1.0, area_ratio / max(self.args.min_gaylord_area_ratio * 4.0, 0.001))
|
|
|
|
if self.args.edge_margin_ratio <= 0:
|
|
cut = False
|
|
else:
|
|
edge_margin_x = frame_w * self.args.edge_margin_ratio
|
|
edge_margin_y = frame_h * self.args.edge_margin_ratio
|
|
cut = (
|
|
x1 <= edge_margin_x
|
|
or y1 <= edge_margin_y
|
|
or x2 >= frame_w - edge_margin_x
|
|
or y2 >= frame_h - edge_margin_y
|
|
)
|
|
cut_score = 0.0 if cut else 1.0
|
|
score = 0.50 * center_score + 0.30 * size_score + 0.20 * cut_score
|
|
|
|
in_center_band = center_delta <= center_tolerance
|
|
on_snapshot_line = center_delta <= snapshot_tolerance
|
|
in_y_band = (
|
|
frame_h * self.args.usable_y_min_ratio
|
|
<= cy
|
|
<= frame_h * self.args.usable_y_max_ratio
|
|
)
|
|
enough_hits = track.hits >= self.args.min_track_hits
|
|
large_enough = area_ratio >= self.args.min_gaylord_area_ratio
|
|
trend_ok = track.area_trend() >= self.args.min_area_trend
|
|
eligible = (
|
|
enough_hits
|
|
and on_snapshot_line
|
|
and in_y_band
|
|
and large_enough
|
|
and not cut
|
|
and trend_ok
|
|
and track.missed == 0
|
|
)
|
|
failed: list[str] = []
|
|
if not enough_hits:
|
|
failed.append(f"hits<{self.args.min_track_hits}")
|
|
if not in_center_band:
|
|
failed.append(f"outside_band={center_delta:.0f}>{center_tolerance:.0f}")
|
|
elif not on_snapshot_line:
|
|
failed.append(f"wait_line={center_delta:.0f}>{snapshot_tolerance:.0f}")
|
|
if not in_y_band:
|
|
failed.append("y_band")
|
|
if not large_enough:
|
|
failed.append(f"area={area_ratio:.3f}<{self.args.min_gaylord_area_ratio:.3f}")
|
|
if cut:
|
|
failed.append("edge_cut")
|
|
if not trend_ok:
|
|
failed.append(f"trend={track.area_trend():+.2f}<{self.args.min_area_trend:+.2f}")
|
|
if track.missed != 0:
|
|
failed.append(f"missed={track.missed}")
|
|
track.last_candidate_reason = "ok" if eligible else ",".join(failed)
|
|
return eligible, {
|
|
"score": score,
|
|
"center_score": center_score,
|
|
"size_score": size_score,
|
|
"cut_score": cut_score,
|
|
}
|
|
|
|
def _update_track_state(self, track: Track, eligible: bool, frame_w: int) -> None:
|
|
if track.already_snapshotted:
|
|
track.state = "snapshotted"
|
|
return
|
|
if track.missed > 0:
|
|
track.state = "exiting"
|
|
return
|
|
cx, _ = bbox_center(track.bbox)
|
|
center_delta = abs(cx - frame_w * 0.5)
|
|
snapshot_tolerance = frame_w * self.args.snapshot_line_tolerance_ratio
|
|
if eligible:
|
|
track.state = "centered"
|
|
elif track.hits < self.args.min_track_hits:
|
|
track.state = "entering"
|
|
elif center_delta <= snapshot_tolerance:
|
|
track.state = "centered"
|
|
elif center_delta <= frame_w * self.args.center_tolerance_ratio:
|
|
track.state = "candidate"
|
|
else:
|
|
track.state = "entering"
|
|
|
|
def _finalize_snapshot(self, track: Track) -> NavigationSnapshot | None:
|
|
if not track.candidates:
|
|
return None
|
|
best = max(track.candidates, key=lambda item: item.score)
|
|
track.candidates.clear()
|
|
track.already_snapshotted = True
|
|
track.state = "snapshotted"
|
|
|
|
self.snapshot_counter += 1
|
|
self.position_counter += 1
|
|
simulated_position = f"gaylord {self.position_counter}"
|
|
debug_name = f"snapshot_{self.snapshot_counter:04d}_track_{track.id:03d}_frame.jpg"
|
|
payload_name = f"snapshot_{self.snapshot_counter:04d}_track_{track.id:03d}_ocr_payload.jpg"
|
|
label_payload_name = f"snapshot_{self.snapshot_counter:04d}_track_{track.id:03d}_label_payload.jpg"
|
|
debug_path = self.output_dir / debug_name
|
|
payload_path = self.output_dir / payload_name
|
|
label_payload_path = self.output_dir / label_payload_name
|
|
cv2.imwrite(str(debug_path), best.frame)
|
|
ocr_payload = crop_with_padding(
|
|
best.frame,
|
|
best.bbox,
|
|
self.args.ocr_payload_pad_ratio,
|
|
)
|
|
cv2.imwrite(str(payload_path), ocr_payload)
|
|
label_payload = crop_with_padding(
|
|
best.frame,
|
|
best.label_bbox,
|
|
self.args.label_payload_pad_ratio,
|
|
)
|
|
cv2.imwrite(str(label_payload_path), label_payload)
|
|
self.last_snapshot_frame = best.frame.copy()
|
|
self.last_ocr_payload_frame = ocr_payload.copy()
|
|
self.last_label_payload_frame = label_payload.copy()
|
|
|
|
gaylord_center = bbox_center(best.bbox)
|
|
label_center = bbox_center(best.label_bbox)
|
|
movement_vector = (
|
|
label_center[0] - gaylord_center[0],
|
|
label_center[1] - gaylord_center[1],
|
|
)
|
|
self.label_movement_arrow = (
|
|
(int(round(gaylord_center[0])), int(round(gaylord_center[1]))),
|
|
(int(round(label_center[0])), int(round(label_center[1]))),
|
|
)
|
|
|
|
snapshot = NavigationSnapshot(
|
|
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,
|
|
label_bbox=best.label_bbox,
|
|
score=best.score,
|
|
debug_frame_path=str(debug_path),
|
|
ocr_payload_path=str(payload_path),
|
|
label_payload_path=str(label_payload_path),
|
|
movement_vector_px=movement_vector,
|
|
)
|
|
self._write_metadata(snapshot)
|
|
self._print_commands(snapshot)
|
|
return snapshot
|
|
|
|
def simulate_remote_response(self, snapshot: NavigationSnapshot) -> str:
|
|
mode = self.args.remote_ack_mode
|
|
if mode == "always-ack":
|
|
result = "ACK"
|
|
elif mode == "always-nack":
|
|
result = "NACK"
|
|
else:
|
|
result = "ACK" if snapshot.snapshot_id % 2 == 1 else "NACK"
|
|
|
|
if result == "ACK":
|
|
self.last_remote_result_text = "ACK_RICEVUTO: codice valido su WMS"
|
|
resume_command = f"RIPARTI_{self.args.scan_direction.upper()}"
|
|
self.last_command_lines.extend([
|
|
self.last_remote_result_text,
|
|
resume_command,
|
|
])
|
|
log("[REMOTE] ACK_RICEVUTO codice valido su WMS")
|
|
log(f"[CMD] {resume_command}")
|
|
else:
|
|
self.last_remote_result_text = "NACK_RICEVUTO: riprovare foto"
|
|
self.last_command_lines.extend([
|
|
self.last_remote_result_text,
|
|
"MICRO_MOVE_CORRETTIVO",
|
|
"SCATTA_FOTO_RETRY",
|
|
])
|
|
log("[REMOTE] NACK_RICEVUTO codice assente/non valido")
|
|
log("[CMD] MICRO_MOVE_CORRETTIVO")
|
|
log("[CMD] SCATTA_FOTO_RETRY")
|
|
return result
|
|
|
|
def apply_wms_result(self, result: WmsResult | None, snapshot: NavigationSnapshot) -> str:
|
|
if result is None:
|
|
self.last_remote_result_text = "WMS_TIMEOUT: nessuna risposta entro il timeout"
|
|
self.last_command_lines.extend([
|
|
self.last_remote_result_text,
|
|
"MICRO_MOVE_CORRETTIVO",
|
|
"SCATTA_FOTO_RETRY",
|
|
])
|
|
log("[WMS] TIMEOUT nessuna risposta entro il timeout")
|
|
return "TIMEOUT"
|
|
|
|
status = result.status.upper()
|
|
if status == "ACK":
|
|
message = result.message or "codice valido su WMS"
|
|
ocr_text = ""
|
|
if result.response:
|
|
ocr_text = str(result.response.get("ocr_text") or result.response.get("fake_ocr_text") or "")
|
|
self.last_remote_result_text = f"WMS_ACK: {message}"
|
|
resume_command = f"RIPARTI_{self.args.scan_direction.upper()}"
|
|
lines = [self.last_remote_result_text]
|
|
if ocr_text:
|
|
lines.append(f"OCR_CODICE {ocr_text}")
|
|
lines.append(resume_command)
|
|
self.last_command_lines.extend(lines)
|
|
log(f"[WMS] ACK request_id={result.request_id} snapshot={result.snapshot_id} {message}")
|
|
log(f"[CMD] {resume_command}")
|
|
return "ACK"
|
|
|
|
if status == "NACK":
|
|
message = result.message or "codice non riconosciuto"
|
|
self.last_remote_result_text = f"WMS_NACK: {message}"
|
|
self.last_command_lines.extend([
|
|
self.last_remote_result_text,
|
|
"MICRO_MOVE_CORRETTIVO",
|
|
"SCATTA_FOTO_RETRY",
|
|
])
|
|
log(f"[WMS] NACK request_id={result.request_id} snapshot={result.snapshot_id} {message}")
|
|
return "NACK"
|
|
|
|
message = result.error or result.message or "errore WMS"
|
|
self.last_remote_result_text = f"WMS_ERROR: {message}"
|
|
self.last_command_lines.extend([
|
|
self.last_remote_result_text,
|
|
"MICRO_MOVE_CORRETTIVO",
|
|
"SCATTA_FOTO_RETRY",
|
|
])
|
|
log(f"[WMS] ERROR request_id={result.request_id} snapshot={result.snapshot_id} {message}")
|
|
return "ERROR"
|
|
|
|
def set_motion_text(self, text: str) -> None:
|
|
self.motion_text = text
|
|
|
|
def _write_metadata(self, snapshot: NavigationSnapshot) -> None:
|
|
record = {
|
|
"snapshot_id": snapshot.snapshot_id,
|
|
"frame_id": snapshot.frame_id,
|
|
"timestamp": snapshot.timestamp,
|
|
"simulated_position": 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),
|
|
"score": snapshot.score,
|
|
"movement_vector_px": {
|
|
"dx": snapshot.movement_vector_px[0],
|
|
"dy": snapshot.movement_vector_px[1],
|
|
},
|
|
"debug_frame_path": snapshot.debug_frame_path,
|
|
"ocr_payload_path": snapshot.ocr_payload_path,
|
|
"label_payload_path": snapshot.label_payload_path,
|
|
}
|
|
with self.metadata_path.open("at", encoding="utf-8") as f:
|
|
f.write(json.dumps(record, ensure_ascii=True) + "\n")
|
|
|
|
def _print_commands(self, snapshot: NavigationSnapshot) -> None:
|
|
self.last_command_text = (
|
|
f"SNAPSHOT {snapshot.snapshot_id:04d} "
|
|
f"track={snapshot.track_id} frame={snapshot.frame_id} "
|
|
f"pos={snapshot.simulated_position} score={snapshot.score:.2f}"
|
|
)
|
|
dx, dy = snapshot.movement_vector_px
|
|
self.last_command_lines = [
|
|
self.last_command_text,
|
|
"STOP",
|
|
f"ASSOCIA_POSIZIONE {snapshot.simulated_position}",
|
|
f"ASSOCIA_ETICHETTA {snapshot.simulated_position} - etichetta {snapshot.snapshot_id}",
|
|
f"CENTRA_ETICHETTA dx={dx:+.0f}px dy={dy:+.0f}px durata={self.args.label_move_sec:.1f}s",
|
|
f"ATTENDI_STABILIZZAZIONE {self.args.label_stabilization_sec:.1f}s",
|
|
f"SCATTA_FOTO_ETICHETTA {Path(snapshot.label_payload_path).name}",
|
|
f"RITORNA_CENTRO_GAYLORD durata={self.args.label_return_sec:.1f}s",
|
|
f"SCATTA_FOTO_GAYLORD {Path(snapshot.debug_frame_path).name}",
|
|
f"INVIA_ROI_REMOTA {Path(snapshot.label_payload_path).name}",
|
|
f"ATTENDI_ACK timeout={self.args.remote_ack_timeout_sec:.1f}s",
|
|
]
|
|
log(f"[NAV] {self.last_command_text}")
|
|
log("[CMD] STOP")
|
|
log(f"[CMD] ASSOCIA_POSIZIONE {snapshot.simulated_position}")
|
|
log(f"[CMD] ASSOCIA_ETICHETTA {snapshot.simulated_position} - etichetta {snapshot.snapshot_id}")
|
|
log(f"[CMD] CENTRA_ETICHETTA dx={dx:+.0f}px dy={dy:+.0f}px durata={self.args.label_move_sec:.1f}s")
|
|
log(f"[CMD] ATTENDI_STABILIZZAZIONE {self.args.label_stabilization_sec:.1f}s")
|
|
log(f"[CMD] SCATTA_FOTO_ETICHETTA {Path(snapshot.label_payload_path).name}")
|
|
log(f"[CMD] RITORNA_CENTRO_GAYLORD durata={self.args.label_return_sec:.1f}s")
|
|
log(f"[CMD] SCATTA_FOTO_GAYLORD {Path(snapshot.debug_frame_path).name}")
|
|
log(f"[CMD] INVIA_ROI_REMOTA {Path(snapshot.label_payload_path).name}")
|
|
log(f"[CMD] ATTENDI_ACK timeout={self.args.remote_ack_timeout_sec:.1f}s")
|
|
|
|
def set_label_sequence_phase(self, snapshot: NavigationSnapshot, phase: str) -> None:
|
|
dx, dy = snapshot.movement_vector_px
|
|
base = [
|
|
self.last_command_text,
|
|
"STOP",
|
|
f"ASSOCIA_POSIZIONE {snapshot.simulated_position}",
|
|
f"ASSOCIA_ETICHETTA {snapshot.simulated_position} - etichetta {snapshot.snapshot_id}",
|
|
]
|
|
if phase == "move":
|
|
detail = [
|
|
f"CENTRA_ETICHETTA dx={dx:+.0f}px dy={dy:+.0f}px durata={self.args.label_move_sec:.1f}s",
|
|
"FRECCIA_MOVIMENTO_GAYLORD_ETICHETTA",
|
|
]
|
|
elif phase == "stabilize":
|
|
detail = [
|
|
"DRONE_CENTRATO_SU_ETICHETTA",
|
|
f"ATTENDI_STABILIZZAZIONE {self.args.label_stabilization_sec:.1f}s",
|
|
]
|
|
elif phase == "capture":
|
|
detail = [
|
|
f"SCATTA_FOTO_ETICHETTA {Path(snapshot.label_payload_path).name}",
|
|
f"INVIA_ROI_REMOTA {Path(snapshot.label_payload_path).name}",
|
|
]
|
|
elif phase == "wait_wms":
|
|
detail = [
|
|
f"INVIA_ROI_REMOTA {Path(snapshot.label_payload_path).name}",
|
|
f"ATTENDI_WMS timeout={self.args.remote_ack_timeout_sec:.1f}s",
|
|
]
|
|
elif phase == "return":
|
|
rdx = -dx
|
|
rdy = -dy
|
|
detail = [
|
|
f"RITORNA_CENTRO_GAYLORD dx={rdx:+.0f}px dy={rdy:+.0f}px durata={self.args.label_return_sec:.1f}s",
|
|
"FRECCIA_RITORNO_ETICHETTA_GAYLORD",
|
|
]
|
|
else:
|
|
detail = []
|
|
self.last_command_lines = base + detail
|
|
|
|
|
|
def parse_args():
|
|
pre = argparse.ArgumentParser(add_help=False)
|
|
pre.add_argument("--config", default=DEFAULT_CONFIG_PATH, help="File configurazione INI")
|
|
pre_args, _ = pre.parse_known_args()
|
|
defaults = load_navigation_config(pre_args.config)
|
|
|
|
ap = argparse.ArgumentParser(parents=[pre])
|
|
ap.add_argument("-v", "--video", default=defaults["video"], help="Percorso video. Se omesso usa webcam 0")
|
|
ap.add_argument(
|
|
"--weights",
|
|
default=defaults["weights"],
|
|
help="Modello Ultralytics .pt",
|
|
)
|
|
ap.add_argument("--ultralytics-device", default=defaults["ultralytics_device"], help="Device Ultralytics: cpu oppure 0")
|
|
ap.add_argument("--yolo-half", action=argparse.BooleanOptionalAction, default=defaults["yolo_half"],
|
|
help="Usa FP16/half precision per YOLO quando il device e' GPU")
|
|
ap.add_argument("--input-size", type=int, default=defaults["input_size"], help="Dimensione input YOLO")
|
|
ap.add_argument("--min-confidence", type=float, default=defaults["min_confidence"], help="Confidenza minima")
|
|
ap.add_argument("--target-class", default=defaults["target_class"], help="Classe da tracciare")
|
|
ap.add_argument("--label-class", default=defaults["label_class"], help="Classe etichetta da associare al gaylord")
|
|
|
|
ap.add_argument("--max-track-missed", type=int, default=defaults["max_track_missed"], help="Frame persi prima di rimuovere una track")
|
|
ap.add_argument("--min-match-score", type=float, default=defaults["min_match_score"], help="Soglia associazione detection-track")
|
|
ap.add_argument("--max-center-distance-ratio", type=float, default=defaults["max_center_distance_ratio"], help="Distanza max centri per matching")
|
|
|
|
ap.add_argument("--center-tolerance-ratio", type=float, default=defaults["center_tolerance_ratio"], help="Mezza ampiezza zona centrale")
|
|
ap.add_argument("--snapshot-line-tolerance-ratio", type=float, default=defaults["snapshot_line_tolerance_ratio"],
|
|
help="Tolleranza stretta dalla linea centrale per scattare")
|
|
ap.add_argument("--usable-y-min-ratio", type=float, default=defaults["usable_y_min_ratio"], help="Limite alto fascia utile Y")
|
|
ap.add_argument("--usable-y-max-ratio", type=float, default=defaults["usable_y_max_ratio"], help="Limite basso fascia utile Y")
|
|
ap.add_argument("--min-track-hits", type=int, default=defaults["min_track_hits"], help="Detection consecutive minime")
|
|
ap.add_argument("--min-gaylord-area-ratio", type=float, default=defaults["min_gaylord_area_ratio"], help="Area bbox minima sul frame")
|
|
ap.add_argument("--edge-margin-ratio", type=float, default=defaults["edge_margin_ratio"], help="Margine per considerare bbox tagliato")
|
|
ap.add_argument("--ocr-payload-pad-ratio", type=float, default=defaults["ocr_payload_pad_ratio"],
|
|
help="Padding intorno al bbox centrale inviato all'OCR remoto")
|
|
ap.add_argument("--label-payload-pad-ratio", type=float, default=defaults["label_payload_pad_ratio"],
|
|
help="Padding intorno al bbox etichetta inviato all'OCR remoto")
|
|
ap.add_argument("--min-area-trend", type=float, default=defaults["min_area_trend"], help="Trend area minimo ammesso")
|
|
ap.add_argument("--snapshot-window-frames", type=int, default=defaults["snapshot_window_frames"], help="Candidati da valutare prima dello snapshot")
|
|
ap.add_argument("--snapshot-output-dir", default=defaults["snapshot_output_dir"], help="Directory snapshot e JSONL")
|
|
ap.add_argument("--remote-ack-timeout-sec", type=float, default=defaults["remote_ack_timeout_sec"],
|
|
help="Tempo simulato di attesa OCR remoto/WMS")
|
|
ap.add_argument("--remote-ack-mode", choices=["always-ack", "always-nack", "alternate"],
|
|
default=defaults["remote_ack_mode"], help="Risposta remota simulata")
|
|
ap.add_argument("--wms-enabled", action="store_true", default=defaults["wms_enabled"],
|
|
help="Invia realmente snapshot al WMS demo")
|
|
ap.add_argument("--wms-server-url", default=defaults["wms_server_url"], help="Endpoint HTTP WMS")
|
|
ap.add_argument("--wms-client-id", default=defaults["wms_client_id"], help="Identificativo client WMS")
|
|
ap.add_argument("--wms-timeout-sec", type=float, default=defaults["wms_timeout_sec"], help="Timeout chiamata WMS")
|
|
ap.add_argument("--wms-queue-max-size", type=int, default=defaults["wms_queue_max_size"], help="Massimo job WMS in coda")
|
|
ap.add_argument("--wms-send-gaylord-debug", action="store_true", default=defaults["wms_send_gaylord_debug"],
|
|
help="Invia anche immagine/debug gaylord al server WMS")
|
|
ap.add_argument("--scan-direction", choices=["destra", "sinistra"], default=defaults["scan_direction"],
|
|
help="Direzione simulata di ripartenza dopo ACK")
|
|
ap.add_argument("--label-move-sec", type=float, default=defaults["label_move_sec"],
|
|
help="Durata simulata movimento verso etichetta")
|
|
ap.add_argument("--label-stabilization-sec", type=float, default=defaults["label_stabilization_sec"],
|
|
help="Attesa simulata stabilizzazione su etichetta")
|
|
ap.add_argument("--label-return-sec", type=float, default=defaults["label_return_sec"],
|
|
help="Durata simulata ritorno al centro gaylord")
|
|
|
|
ap.add_argument("--preview-width", type=int, default=defaults["preview_width"], help="Larghezza preview")
|
|
ap.add_argument("--benchmark-mode", action=argparse.BooleanOptionalAction, default=defaults["benchmark_mode"],
|
|
help="Profilo benchmark: niente finestre, preview a 30 fps e log tempi dedicato")
|
|
ap.add_argument("--benchmark-preview-fps", type=float, default=defaults["benchmark_preview_fps"],
|
|
help="FPS target preview/cattura usati dal profilo benchmark")
|
|
ap.add_argument("--realtime-playback", action="store_true", default=defaults["realtime_playback"], help="Rispetta FPS video")
|
|
ap.add_argument("--preview-fps", type=float, default=defaults["preview_fps"],
|
|
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")
|
|
ap.add_argument("--perf-log-flush-interval-sec", type=float, default=defaults["perf_log_flush_interval_sec"],
|
|
help="Intervallo flush file log tempi")
|
|
ap.add_argument("--perf-log-flush-lines", type=int, default=defaults["perf_log_flush_lines"],
|
|
help="Numero righe bufferizzate prima del flush del log tempi")
|
|
ap.add_argument("--motion-report-interval", type=int, default=defaults["motion_report_interval"],
|
|
help="Ogni quanti frame aggiornare la direzione moto stimata")
|
|
ap.add_argument("--motion-min-pixels", type=float, default=defaults["motion_min_pixels"],
|
|
help="Spostamento medio minimo per dichiarare una direzione")
|
|
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")
|
|
ap.add_argument("--commands-window", default=defaults["commands_window"], help="Layout finestra comandi: x,y,w,h")
|
|
ap.add_argument("--snapshot-window", default=defaults["snapshot_window"], help="Layout finestra snapshot: x,y,w,h")
|
|
ap.add_argument("--label-window", default=defaults["label_window"], help="Layout finestra etichetta: x,y,w,h")
|
|
return ap.parse_args()
|
|
|
|
|
|
def load_navigation_config(path_str: str) -> dict[str, object]:
|
|
defaults: dict[str, object] = {
|
|
"video": "testhd.mp4",
|
|
"weights": r"C:\devel\flywms\runs\flywms_yolo11n_quick20\weights\best.pt",
|
|
"ultralytics_device": "cpu",
|
|
"yolo_half": True,
|
|
"input_size": 640,
|
|
"min_confidence": 0.25,
|
|
"target_class": "gaylord",
|
|
"label_class": "etichetta",
|
|
"max_track_missed": 8,
|
|
"min_match_score": 0.25,
|
|
"max_center_distance_ratio": 0.18,
|
|
"center_tolerance_ratio": 0.18,
|
|
"snapshot_line_tolerance_ratio": 0.035,
|
|
"usable_y_min_ratio": 0.15,
|
|
"usable_y_max_ratio": 0.85,
|
|
"min_track_hits": 3,
|
|
"min_gaylord_area_ratio": 0.02,
|
|
"edge_margin_ratio": 0.0,
|
|
"ocr_payload_pad_ratio": 0.03,
|
|
"label_payload_pad_ratio": 0.20,
|
|
"min_area_trend": -0.35,
|
|
"snapshot_window_frames": 1,
|
|
"snapshot_output_dir": "navigate_snapshots",
|
|
"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",
|
|
"wms_client_id": "flywms-demo-01",
|
|
"wms_timeout_sec": 2.0,
|
|
"wms_queue_max_size": 8,
|
|
"wms_send_gaylord_debug": True,
|
|
"scan_direction": "destra",
|
|
"label_move_sec": 3.0,
|
|
"label_stabilization_sec": 2.0,
|
|
"label_return_sec": 3.0,
|
|
"preview_width": 1280,
|
|
"benchmark_mode": False,
|
|
"benchmark_preview_fps": 30.0,
|
|
"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",
|
|
"perf_log_flush_interval_sec": 2.0,
|
|
"perf_log_flush_lines": 120,
|
|
"motion_report_interval": 5,
|
|
"motion_min_pixels": 1.5,
|
|
"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",
|
|
"snapshot_window": "1140,590,520,360",
|
|
"label_window": "1140,980,520,260",
|
|
}
|
|
|
|
path = Path(path_str)
|
|
if not path.exists():
|
|
return defaults
|
|
|
|
parser = configparser.ConfigParser()
|
|
parser.read(path, encoding="utf-8")
|
|
section = parser["navigation"] if parser.has_section("navigation") else {}
|
|
|
|
types = {key: type(value) for key, value in defaults.items()}
|
|
for key, default_value in defaults.items():
|
|
if key not in section:
|
|
continue
|
|
if types[key] is bool:
|
|
defaults[key] = parser.getboolean("navigation", key, fallback=bool(default_value))
|
|
elif types[key] is int:
|
|
defaults[key] = parser.getint("navigation", key, fallback=int(default_value))
|
|
elif types[key] is float:
|
|
defaults[key] = parser.getfloat("navigation", key, fallback=float(default_value))
|
|
else:
|
|
value = section.get(key, str(default_value)).strip()
|
|
defaults[key] = None if value.lower() in ("", "none", "null") else value
|
|
return defaults
|
|
|
|
|
|
def log(msg: str) -> None:
|
|
print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
|
|
|
|
|
|
def format_fps_value(value: float | int | None) -> str:
|
|
if value is None:
|
|
return "n/d"
|
|
try:
|
|
numeric = float(value)
|
|
except (TypeError, ValueError):
|
|
return "n/d"
|
|
if numeric <= 0:
|
|
return "n/d"
|
|
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)
|
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
self.file = self.path.open("a", encoding="utf-8")
|
|
self.flush_interval_sec = max(0.2, float(flush_interval_sec))
|
|
self.flush_lines = max(1, int(flush_lines))
|
|
self.buffered_lines = 0
|
|
self.last_flush = time.perf_counter()
|
|
|
|
def write(self, line: str) -> None:
|
|
self.file.write(line.rstrip("\n") + "\n")
|
|
self.buffered_lines += 1
|
|
self.maybe_flush()
|
|
|
|
def maybe_flush(self) -> None:
|
|
now = time.perf_counter()
|
|
if self.buffered_lines >= self.flush_lines or (now - self.last_flush) >= self.flush_interval_sec:
|
|
self.file.flush()
|
|
self.buffered_lines = 0
|
|
self.last_flush = now
|
|
|
|
def close(self) -> None:
|
|
try:
|
|
self.file.flush()
|
|
finally:
|
|
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():
|
|
log(f"ERRORE: {description} non trovato: {path}")
|
|
sys.exit(1)
|
|
return path
|
|
|
|
|
|
def open_capture(video_arg: str | None):
|
|
if video_arg is None:
|
|
cap = cv2.VideoCapture(0, cv2.CAP_DSHOW)
|
|
if not cap.isOpened():
|
|
cap = cv2.VideoCapture(0)
|
|
return cap, "camera:0"
|
|
|
|
if str(video_arg).isdigit():
|
|
idx = int(video_arg)
|
|
cap = cv2.VideoCapture(idx, cv2.CAP_DSHOW)
|
|
if not cap.isOpened():
|
|
cap = cv2.VideoCapture(idx)
|
|
return cap, f"camera:{idx}"
|
|
|
|
return cv2.VideoCapture(video_arg), str(video_arg)
|
|
|
|
|
|
def build_wms_multipart(job: WmsSnapshotJob) -> tuple[bytes, str]:
|
|
boundary = f"flywms-{uuid.uuid4().hex}"
|
|
chunks: list[bytes] = []
|
|
|
|
def add_field(name: str, value: str, content_type: str = "text/plain") -> None:
|
|
chunks.extend([
|
|
f"--{boundary}\r\n".encode("ascii"),
|
|
f'Content-Disposition: form-data; name="{name}"\r\n'.encode("ascii"),
|
|
f"Content-Type: {content_type}\r\n\r\n".encode("ascii"),
|
|
value.encode("utf-8"),
|
|
b"\r\n",
|
|
])
|
|
|
|
def add_file(name: str, path_str: str) -> None:
|
|
path = Path(path_str)
|
|
chunks.extend([
|
|
f"--{boundary}\r\n".encode("ascii"),
|
|
(
|
|
f'Content-Disposition: form-data; name="{name}"; '
|
|
f'filename="{path.name}"\r\n'
|
|
).encode("utf-8"),
|
|
b"Content-Type: image/jpeg\r\n\r\n",
|
|
path.read_bytes(),
|
|
b"\r\n",
|
|
])
|
|
|
|
add_field("metadata", json.dumps(job.metadata, ensure_ascii=True), "application/json")
|
|
add_file("label_image", job.label_image_path)
|
|
if job.gaylord_image_path is not None:
|
|
add_file("gaylord_image", job.gaylord_image_path)
|
|
chunks.append(f"--{boundary}--\r\n".encode("ascii"))
|
|
return b"".join(chunks), f"multipart/form-data; boundary={boundary}"
|
|
|
|
|
|
def parse_window_rect(value: str | None) -> tuple[int, int, int, int] | None:
|
|
if value is None:
|
|
return None
|
|
parts = [part.strip() for part in str(value).split(",")]
|
|
if len(parts) != 4:
|
|
return None
|
|
try:
|
|
x, y, w, h = [int(part) for part in parts]
|
|
except ValueError:
|
|
return None
|
|
if w <= 0 or h <= 0:
|
|
return None
|
|
return x, y, w, h
|
|
|
|
|
|
def apply_window_layout(args) -> None:
|
|
if not args.window_layout_enabled:
|
|
return
|
|
layout = {
|
|
"flywms navigate": args.navigate_window,
|
|
"flywms comandi": args.commands_window,
|
|
"flywms snapshot": args.snapshot_window,
|
|
"flywms etichetta": args.label_window,
|
|
}
|
|
for name, value in layout.items():
|
|
rect = parse_window_rect(value)
|
|
if rect is None:
|
|
log(f"Layout finestra ignorato per {name}: {value}")
|
|
continue
|
|
x, y, w, h = rect
|
|
cv2.resizeWindow(name, w, h)
|
|
cv2.moveWindow(name, x, y)
|
|
|
|
|
|
def clip_box(x1: int, y1: int, x2: int, y2: int, w: int, h: int) -> tuple[int, int, int, int]:
|
|
x1 = max(0, min(x1, w - 1))
|
|
y1 = max(0, min(y1, h - 1))
|
|
x2 = max(0, min(x2, w - 1))
|
|
y2 = max(0, min(y2, h - 1))
|
|
return x1, y1, x2, y2
|
|
|
|
|
|
def crop_with_padding(
|
|
frame: np.ndarray,
|
|
bbox: tuple[int, int, int, int],
|
|
pad_ratio: float,
|
|
) -> np.ndarray:
|
|
x1, y1, x2, y2 = bbox
|
|
bw = x2 - x1
|
|
bh = y2 - y1
|
|
pad_x = int(max(0.0, pad_ratio) * bw)
|
|
pad_y = int(max(0.0, pad_ratio) * bh)
|
|
cx1, cy1, cx2, cy2 = clip_box(
|
|
x1 - pad_x,
|
|
y1 - pad_y,
|
|
x2 + pad_x,
|
|
y2 + pad_y,
|
|
frame.shape[1],
|
|
frame.shape[0],
|
|
)
|
|
return frame[cy1:cy2, cx1:cx2].copy()
|
|
|
|
|
|
def bbox_area(bbox: tuple[int, int, int, int]) -> int:
|
|
x1, y1, x2, y2 = bbox
|
|
return max(0, x2 - x1) * max(0, y2 - y1)
|
|
|
|
|
|
def bbox_center(bbox: tuple[int, int, int, int]) -> tuple[float, float]:
|
|
x1, y1, x2, y2 = bbox
|
|
return (x1 + x2) * 0.5, (y1 + y2) * 0.5
|
|
|
|
|
|
def bbox_contains(
|
|
outer: tuple[int, int, int, int],
|
|
inner: tuple[int, int, int, int],
|
|
) -> bool:
|
|
ox1, oy1, ox2, oy2 = outer
|
|
ix1, iy1, ix2, iy2 = inner
|
|
return ox1 <= ix1 and oy1 <= iy1 and ix2 <= ox2 and iy2 <= oy2
|
|
|
|
|
|
def find_label_inside_bbox(
|
|
gaylord_bbox: tuple[int, int, int, int],
|
|
labels: list[Detection],
|
|
) -> Detection | None:
|
|
contained = [label for label in labels if bbox_contains(gaylord_bbox, label.bbox)]
|
|
if not contained:
|
|
return None
|
|
if len(contained) == 1:
|
|
return contained[0]
|
|
# Multiple contained labels should not happen; choose a deterministic fallback.
|
|
gx, gy = bbox_center(gaylord_bbox)
|
|
return min(
|
|
contained,
|
|
key=lambda label: (
|
|
np.hypot(bbox_center(label.bbox)[0] - gx, bbox_center(label.bbox)[1] - gy),
|
|
-label.confidence,
|
|
),
|
|
)
|
|
|
|
|
|
def bbox_iou(a: tuple[int, int, int, int], b: tuple[int, int, int, int]) -> float:
|
|
ax1, ay1, ax2, ay2 = a
|
|
bx1, by1, bx2, by2 = b
|
|
ix1 = max(ax1, bx1)
|
|
iy1 = max(ay1, by1)
|
|
ix2 = min(ax2, bx2)
|
|
iy2 = min(ay2, by2)
|
|
inter = bbox_area((ix1, iy1, ix2, iy2))
|
|
union = bbox_area(a) + bbox_area(b) - inter
|
|
if union <= 0:
|
|
return 0.0
|
|
return inter / float(union)
|
|
|
|
|
|
def association_score(
|
|
track_bbox: tuple[int, int, int, int],
|
|
det_bbox: tuple[int, int, int, int],
|
|
max_center_distance: float,
|
|
) -> float:
|
|
iou = bbox_iou(track_bbox, det_bbox)
|
|
tx, ty = bbox_center(track_bbox)
|
|
dx, dy = bbox_center(det_bbox)
|
|
center_dist = float(np.hypot(tx - dx, ty - dy))
|
|
center_similarity = max(0.0, 1.0 - center_dist / max_center_distance)
|
|
return 0.70 * iou + 0.30 * center_similarity
|
|
|
|
|
|
def resize_preview(frame: np.ndarray, max_width: int) -> np.ndarray:
|
|
h, w = frame.shape[:2]
|
|
if max_width <= 0 or w <= max_width:
|
|
return frame
|
|
scale = max_width / float(w)
|
|
return cuda_resize(frame, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_LINEAR)
|
|
|
|
|
|
def draw_navigation_debug(
|
|
frame: np.ndarray,
|
|
tracks: list[Track],
|
|
args,
|
|
labels: list[Detection] | None = None,
|
|
movement_arrow: tuple[tuple[int, int], tuple[int, int]] | None = None,
|
|
) -> np.ndarray:
|
|
labels = labels or []
|
|
display = frame.copy()
|
|
h, w = display.shape[:2]
|
|
center_x = int(w * 0.5)
|
|
tol = int(w * args.center_tolerance_ratio)
|
|
y_min = int(h * args.usable_y_min_ratio)
|
|
y_max = int(h * args.usable_y_max_ratio)
|
|
|
|
cv2.rectangle(display, (center_x - tol, y_min), (center_x + tol, y_max), (255, 255, 0), 4)
|
|
cv2.line(display, (center_x, 0), (center_x, h), (255, 255, 0), 3)
|
|
cv2.line(display, (0, y_min), (w, y_min), (100, 100, 100), 2)
|
|
cv2.line(display, (0, y_max), (w, y_max), (100, 100, 100), 2)
|
|
|
|
for track in tracks:
|
|
x1, y1, x2, y2 = track.bbox
|
|
color = state_color(track.state)
|
|
thickness = 8 if track.state == "centered" else 5
|
|
cv2.rectangle(display, (x1, y1), (x2, y2), color, thickness)
|
|
cx, cy = bbox_center(track.bbox)
|
|
cv2.circle(display, (int(cx), int(cy)), 12, color, -1)
|
|
cv2.circle(display, (int(cx), int(cy)), 18, (0, 0, 0), 3)
|
|
|
|
for label in labels:
|
|
x1, y1, x2, y2 = label.bbox
|
|
cv2.rectangle(display, (x1, y1), (x2, y2), (0, 170, 255), 4)
|
|
lx, ly = bbox_center(label.bbox)
|
|
cv2.circle(display, (int(lx), int(ly)), 8, (0, 170, 255), -1)
|
|
|
|
if movement_arrow is not None:
|
|
start, end = movement_arrow
|
|
cv2.arrowedLine(display, start, end, (0, 0, 0), 12, cv2.LINE_AA, tipLength=0.12)
|
|
cv2.arrowedLine(display, end, start, (0, 0, 0), 12, cv2.LINE_AA, tipLength=0.12)
|
|
|
|
return resize_preview(display, args.preview_width)
|
|
|
|
|
|
def draw_commands_window(command_lines: list[str], motion_text: str) -> np.ndarray:
|
|
lines = command_lines if command_lines else ["Nessun comando generato"]
|
|
visible_lines = lines[:14]
|
|
arrow_mode = command_arrow_mode(lines)
|
|
arrow_h = 210 if arrow_mode else 0
|
|
canvas_h = max(340, 84 + len(visible_lines) * 34 + arrow_h)
|
|
canvas = np.full((canvas_h, 980, 3), 245, dtype=np.uint8)
|
|
cv2.putText(
|
|
canvas,
|
|
"COMANDI NAVIGAZIONE",
|
|
(24, 42),
|
|
cv2.FONT_HERSHEY_SIMPLEX,
|
|
1.0,
|
|
(0, 0, 0),
|
|
2,
|
|
cv2.LINE_AA,
|
|
)
|
|
cv2.putText(
|
|
canvas,
|
|
motion_text,
|
|
(24, 76),
|
|
cv2.FONT_HERSHEY_SIMPLEX,
|
|
0.80,
|
|
(120, 0, 120),
|
|
2,
|
|
cv2.LINE_AA,
|
|
)
|
|
y = 122
|
|
for idx, line in enumerate(visible_lines):
|
|
color = (0, 0, 180) if idx == 0 else (0, 90, 0)
|
|
cv2.putText(
|
|
canvas,
|
|
line,
|
|
(24, y),
|
|
cv2.FONT_HERSHEY_SIMPLEX,
|
|
0.82,
|
|
color,
|
|
2,
|
|
cv2.LINE_AA,
|
|
)
|
|
y += 36
|
|
if arrow_mode:
|
|
draw_command_arrow(canvas, arrow_mode, y + 16)
|
|
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"):
|
|
return "to_label", *parse_command_delta(line)
|
|
if line.startswith("RITORNA_CENTRO_GAYLORD"):
|
|
dx, dy = parse_command_delta(line)
|
|
return "to_gaylord", -dx, -dy
|
|
return None
|
|
|
|
|
|
def parse_command_delta(line: str) -> tuple[float, float]:
|
|
dx = 0.0
|
|
dy = 0.0
|
|
for part in line.split():
|
|
if part.startswith("dx="):
|
|
dx = float(part[3:].replace("px", ""))
|
|
elif part.startswith("dy="):
|
|
dy = float(part[3:].replace("px", ""))
|
|
return dx, dy
|
|
|
|
|
|
def draw_command_arrow(canvas: np.ndarray, arrow_info: tuple[str, float, float], y0: int) -> None:
|
|
mode, dx, dy = arrow_info
|
|
h, w = canvas.shape[:2]
|
|
y0 = min(max(y0, 110), h - 160)
|
|
center = (w // 2 - 120, y0 + 130)
|
|
northeast = (w // 2 + 150, y0 + 34)
|
|
if mode == "to_label":
|
|
start, end = center, northeast
|
|
title = "MOVIMENTO VERSO ETICHETTA"
|
|
start_label = "CENTRO GAYLORD"
|
|
end_label = "ETICHETTA"
|
|
else:
|
|
start, end = northeast, center
|
|
title = "RITORNO AL CENTRO GAYLORD"
|
|
start_label = "ETICHETTA"
|
|
end_label = "CENTRO GAYLORD"
|
|
|
|
cv2.rectangle(canvas, (24, y0), (w - 24, min(h - 18, y0 + 170)), (232, 232, 232), -1)
|
|
cv2.rectangle(canvas, (24, y0), (w - 24, min(h - 18, y0 + 170)), (120, 120, 120), 2)
|
|
cv2.putText(canvas, title, (44, y0 + 34), cv2.FONT_HERSHEY_SIMPLEX, 0.86, (0, 0, 0), 2, cv2.LINE_AA)
|
|
cv2.putText(canvas, f"DX ORIZZONTALE: {dx:+.0f} px", (44, y0 + 72), cv2.FONT_HERSHEY_SIMPLEX, 0.86, (0, 0, 180), 2, cv2.LINE_AA)
|
|
cv2.putText(canvas, f"DY VERTICALE: {dy:+.0f} px", (44, y0 + 108), cv2.FONT_HERSHEY_SIMPLEX, 0.86, (0, 0, 180), 2, cv2.LINE_AA)
|
|
cv2.circle(canvas, center, 16, (70, 70, 70), -1)
|
|
cv2.circle(canvas, northeast, 16, (0, 120, 255), -1)
|
|
cv2.arrowedLine(canvas, start, end, (0, 0, 0), 12, cv2.LINE_AA, tipLength=0.22)
|
|
cv2.putText(canvas, start_label, (start[0] - 115, start[1] + 42), cv2.FONT_HERSHEY_SIMPLEX, 0.62, (0, 0, 0), 2, cv2.LINE_AA)
|
|
cv2.putText(canvas, end_label, (end[0] - 78, max(24, end[1] - 24)), cv2.FONT_HERSHEY_SIMPLEX, 0.62, (0, 0, 0), 2, cv2.LINE_AA)
|
|
|
|
|
|
def apply_flash(frame: np.ndarray, alpha: float) -> np.ndarray:
|
|
flash = np.full_like(frame, 255)
|
|
alpha = min(max(alpha, 0.0), 1.0)
|
|
return cv2.addWeighted(frame, 1.0 - alpha, flash, alpha, 0.0)
|
|
|
|
|
|
def estimate_motion_from_tracks(tracks: list[Track], min_pixels: float) -> str:
|
|
deltas: list[tuple[float, float]] = []
|
|
for track in tracks:
|
|
if track.missed != 0 or len(track.center_history) < 2:
|
|
continue
|
|
x0, y0 = track.center_history[-2]
|
|
x1, y1 = track.center_history[-1]
|
|
deltas.append((x1 - x0, y1 - y0))
|
|
|
|
if not deltas:
|
|
return "MOTO: n/d"
|
|
|
|
dx = sum(delta[0] for delta in deltas) / len(deltas)
|
|
dy = sum(delta[1] for delta in deltas) / len(deltas)
|
|
abs_dx = abs(dx)
|
|
abs_dy = abs(dy)
|
|
|
|
if abs_dx < min_pixels and abs_dy < min_pixels:
|
|
direction = "stabile"
|
|
elif abs_dx >= abs_dy:
|
|
direction = "destra" if dx > 0 else "sinistra"
|
|
else:
|
|
direction = "giu" if dy > 0 else "su"
|
|
|
|
return f"MOTO: {direction} dx={dx:+.1f}px dy={dy:+.1f}px tracks={len(deltas)}"
|
|
|
|
|
|
def state_color(state: str) -> tuple[int, int, int]:
|
|
if state == "centered":
|
|
return (0, 255, 255)
|
|
if state == "snapshotted":
|
|
return (255, 0, 255)
|
|
if state == "candidate":
|
|
return (0, 255, 0)
|
|
if state == "exiting":
|
|
return (0, 140, 255)
|
|
return (255, 255, 255)
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
if args.benchmark_mode:
|
|
args.no_display = True
|
|
args.window_layout_enabled = False
|
|
args.realtime_playback = True
|
|
args.preview_fps = args.benchmark_preview_fps
|
|
if args.perf_log_path == "tempistiche.txt":
|
|
args.perf_log_path = "tempistiche-benchmark.txt"
|
|
log(
|
|
f"Profilo benchmark attivo: no_display=true "
|
|
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)
|
|
log(f"Classi modello: {detector.classes}")
|
|
log(f"OpenCV CUDA: {'attivo' if OPENCV_CUDA_AVAILABLE else 'non disponibile'}")
|
|
log(f"YOLO half precision: {'attiva' if detector.half else 'disattiva'}")
|
|
log("Nota tracker: questa versione usa tracking geometrico interno; ByteTrack/BoT-SORT restano candidati per confronto successivo.")
|
|
|
|
cap, source_name = open_capture(args.video)
|
|
if not cap.isOpened():
|
|
log(f"ERRORE: impossibile aprire sorgente video: {source_name}")
|
|
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
|
|
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,
|
|
args.perf_log_flush_lines,
|
|
)
|
|
log(f"Log tempistiche: {perf_writer.path.resolve()}")
|
|
perf_writer.write(
|
|
"ts\tframe_id\trun_yolo\tread_ms\tyolo_ms\ttrack_ms\tdraw_ms\tui_ms\t"
|
|
"snapshot_pause_ms\twms_wait_ms\tloop_ms\tloop_fps\tyolo_real_fps\t"
|
|
"src_fps\tpreview_target\tyolo_target\tdet\tlabels\ttracks\tactive\t"
|
|
"snapshots\tcommand"
|
|
)
|
|
tracker = LightweightTracker(
|
|
max_missed=args.max_track_missed,
|
|
min_match_score=args.min_match_score,
|
|
max_center_distance_ratio=args.max_center_distance_ratio,
|
|
)
|
|
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)
|
|
cv2.namedWindow("flywms snapshot", cv2.WINDOW_NORMAL)
|
|
cv2.namedWindow("flywms etichetta", cv2.WINDOW_NORMAL)
|
|
cv2.namedWindow("flywms comandi", cv2.WINDOW_NORMAL)
|
|
apply_window_layout(args)
|
|
|
|
frame_id = 0
|
|
processed_frames = 0
|
|
start_time = time.perf_counter()
|
|
last_stats = 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()
|
|
read_ms = 0.0
|
|
track_ms = 0.0
|
|
draw_ms = 0.0
|
|
ui_ms = 0.0
|
|
snapshot_pause_ms = 0.0
|
|
wms_wait_ms = 0.0
|
|
try:
|
|
captured = capture_worker.get(timeout=2.0)
|
|
except queue.Empty:
|
|
continue
|
|
if captured is None:
|
|
log("Fine stream")
|
|
break
|
|
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
|
|
detections, last_yolo_ms = detector.detect(frame, args.min_confidence, args.input_size)
|
|
yolo_total_ms += last_yolo_ms
|
|
yolo_cycles += 1
|
|
gaylords = [
|
|
det for det in detections
|
|
if det.class_name.strip().lower() == args.target_class.strip().lower()
|
|
]
|
|
labels = [
|
|
det for det in detections
|
|
if det.class_name.strip().lower() == args.label_class.strip().lower()
|
|
]
|
|
|
|
track_t0 = time.perf_counter()
|
|
tracks = tracker.update(gaylords, frame_id, frame.shape[1])
|
|
if args.motion_report_interval > 0 and yolo_cycles % args.motion_report_interval == 0:
|
|
navigator.set_motion_text(
|
|
estimate_motion_from_tracks(tracks, args.motion_min_pixels)
|
|
)
|
|
for track in tracks:
|
|
if track.missed == 0:
|
|
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:
|
|
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()
|
|
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)
|
|
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:
|
|
elapsed = max(now - start_time, 0.001)
|
|
avg_yolo = yolo_total_ms / max(yolo_cycles, 1)
|
|
active = sum(1 for t in tracks if t.missed == 0)
|
|
log(
|
|
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}"
|
|
)
|
|
if args.debug_tracks:
|
|
for track in tracks:
|
|
cx, cy = bbox_center(track.bbox)
|
|
area_ratio = bbox_area(track.bbox) / float(frame.shape[0] * frame.shape[1])
|
|
log(
|
|
f" track={track.id} state={track.state} hits={track.hits} "
|
|
f"missed={track.missed} center=({cx:.0f},{cy:.0f}) "
|
|
f"area={area_ratio:.3f} trend={track.area_trend():+.2f} "
|
|
f"reason={track.last_candidate_reason}"
|
|
)
|
|
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(
|
|
frame,
|
|
tracks,
|
|
args,
|
|
labels,
|
|
navigator.label_movement_arrow,
|
|
)
|
|
draw_ms += (time.perf_counter() - draw_t0) * 1000.0
|
|
ui_t0 = time.perf_counter()
|
|
cv2.imshow("flywms navigate", display)
|
|
if navigator.last_ocr_payload_frame is not None:
|
|
snapshot_display = resize_preview(navigator.last_ocr_payload_frame, args.preview_width)
|
|
cv2.imshow("flywms snapshot", snapshot_display)
|
|
if navigator.last_label_payload_frame is not None and not new_snapshots:
|
|
label_display = resize_preview(navigator.last_label_payload_frame, args.preview_width)
|
|
cv2.imshow("flywms etichetta", label_display)
|
|
cv2.imshow(
|
|
"flywms comandi",
|
|
draw_commands_window(navigator.last_command_lines, navigator.motion_text),
|
|
)
|
|
ui_ms += (time.perf_counter() - ui_t0) * 1000.0
|
|
|
|
if new_snapshots:
|
|
stop_requested = False
|
|
for snapshot in new_snapshots:
|
|
navigator.set_label_sequence_phase(snapshot, "move")
|
|
ui_t0 = time.perf_counter()
|
|
cv2.imshow("flywms navigate", display)
|
|
cv2.imshow(
|
|
"flywms comandi",
|
|
draw_commands_window(navigator.last_command_lines, navigator.motion_text),
|
|
)
|
|
ui_ms += (time.perf_counter() - ui_t0) * 1000.0
|
|
wait_t0 = time.perf_counter()
|
|
key = cv2.waitKey(max(1, int(args.label_move_sec * 1000))) & 0xFF
|
|
snapshot_pause_ms += (time.perf_counter() - wait_t0) * 1000.0
|
|
if key in (27, ord("q")):
|
|
log("Interrotto da tastiera")
|
|
stop_requested = True
|
|
break
|
|
|
|
navigator.set_label_sequence_phase(snapshot, "stabilize")
|
|
ui_t0 = time.perf_counter()
|
|
cv2.imshow("flywms navigate", display)
|
|
cv2.imshow(
|
|
"flywms comandi",
|
|
draw_commands_window(navigator.last_command_lines, navigator.motion_text),
|
|
)
|
|
ui_ms += (time.perf_counter() - ui_t0) * 1000.0
|
|
wait_t0 = time.perf_counter()
|
|
key = cv2.waitKey(max(1, int(args.label_stabilization_sec * 1000))) & 0xFF
|
|
snapshot_pause_ms += (time.perf_counter() - wait_t0) * 1000.0
|
|
if key in (27, ord("q")):
|
|
log("Interrotto da tastiera")
|
|
stop_requested = True
|
|
break
|
|
|
|
navigator.set_label_sequence_phase(snapshot, "capture")
|
|
flash_display = apply_flash(display, args.flash_alpha)
|
|
ui_t0 = time.perf_counter()
|
|
cv2.imshow("flywms navigate", flash_display)
|
|
if navigator.last_label_payload_frame is not None:
|
|
flash_label = apply_flash(
|
|
resize_preview(navigator.last_label_payload_frame, args.preview_width),
|
|
args.flash_alpha,
|
|
)
|
|
cv2.imshow("flywms etichetta", flash_label)
|
|
cv2.imshow(
|
|
"flywms comandi",
|
|
draw_commands_window(navigator.last_command_lines, navigator.motion_text),
|
|
)
|
|
ui_ms += (time.perf_counter() - ui_t0) * 1000.0
|
|
wait_t0 = time.perf_counter()
|
|
key = cv2.waitKey(500) & 0xFF
|
|
snapshot_pause_ms += (time.perf_counter() - wait_t0) * 1000.0
|
|
if key in (27, ord("q")):
|
|
log("Interrotto da tastiera")
|
|
stop_requested = True
|
|
break
|
|
|
|
request_id = wms_client.submit(snapshot) if args.wms_enabled else None
|
|
|
|
navigator.set_label_sequence_phase(snapshot, "return")
|
|
ui_t0 = time.perf_counter()
|
|
cv2.imshow("flywms navigate", display)
|
|
if navigator.last_label_payload_frame is not None:
|
|
cv2.imshow(
|
|
"flywms etichetta",
|
|
resize_preview(navigator.last_label_payload_frame, args.preview_width),
|
|
)
|
|
cv2.imshow(
|
|
"flywms comandi",
|
|
draw_commands_window(navigator.last_command_lines, navigator.motion_text),
|
|
)
|
|
ui_ms += (time.perf_counter() - ui_t0) * 1000.0
|
|
wait_t0 = time.perf_counter()
|
|
key = cv2.waitKey(max(1, int(args.label_return_sec * 1000))) & 0xFF
|
|
snapshot_pause_ms += (time.perf_counter() - wait_t0) * 1000.0
|
|
if key in (27, ord("q")):
|
|
log("Interrotto da tastiera")
|
|
stop_requested = True
|
|
break
|
|
|
|
navigator.set_label_sequence_phase(snapshot, "wait_wms")
|
|
ui_t0 = time.perf_counter()
|
|
cv2.imshow("flywms navigate", display)
|
|
if navigator.last_label_payload_frame is not None:
|
|
cv2.imshow(
|
|
"flywms etichetta",
|
|
resize_preview(navigator.last_label_payload_frame, args.preview_width),
|
|
)
|
|
cv2.imshow(
|
|
"flywms comandi",
|
|
draw_commands_window(navigator.last_command_lines, navigator.motion_text),
|
|
)
|
|
ui_ms += (time.perf_counter() - ui_t0) * 1000.0
|
|
wait_deadline = time.perf_counter() + max(0.0, args.remote_ack_timeout_sec)
|
|
wms_result: WmsResult | None = None
|
|
if args.wms_enabled:
|
|
wait_t0 = time.perf_counter()
|
|
while time.perf_counter() <= wait_deadline:
|
|
wms_result = wms_client.wait_for_result(request_id, 0.05)
|
|
key = cv2.waitKey(1) & 0xFF
|
|
if key in (27, ord("q")):
|
|
log("Interrotto da tastiera")
|
|
stop_requested = True
|
|
break
|
|
if wms_result is not None:
|
|
break
|
|
wms_wait_ms += (time.perf_counter() - wait_t0) * 1000.0
|
|
else:
|
|
wait_t0 = time.perf_counter()
|
|
key = cv2.waitKey(max(1, int(args.remote_ack_timeout_sec * 1000))) & 0xFF
|
|
wms_wait_ms += (time.perf_counter() - wait_t0) * 1000.0
|
|
if key in (27, ord("q")):
|
|
log("Interrotto da tastiera")
|
|
stop_requested = True
|
|
if stop_requested:
|
|
break
|
|
if args.wms_enabled:
|
|
navigator.apply_wms_result(wms_result, snapshot)
|
|
else:
|
|
navigator.simulate_remote_response(snapshot)
|
|
cv2.imshow(
|
|
"flywms comandi",
|
|
draw_commands_window(navigator.last_command_lines, navigator.motion_text),
|
|
)
|
|
|
|
navigator.label_movement_arrow = None
|
|
if stop_requested:
|
|
break
|
|
|
|
ui_t0 = time.perf_counter()
|
|
key = cv2.waitKey(1) & 0xFF
|
|
ui_ms += (time.perf_counter() - ui_t0) * 1000.0
|
|
if key in (27, ord("q")):
|
|
log("Interrotto da tastiera")
|
|
break
|
|
|
|
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{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
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|