You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
317 lines
11 KiB
Python
317 lines
11 KiB
Python
import json
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import cv2
|
|
import numpy as np
|
|
|
|
from config import *
|
|
from helpers import box_center, clamp, clip_box
|
|
|
|
|
|
def _curve_command(value, deadzone, expo, gain):
|
|
v = float(np.clip(value, -1.0, 1.0))
|
|
av = abs(v)
|
|
if av <= deadzone:
|
|
return 0.0
|
|
t = (av - deadzone) / max(1e-6, 1.0 - deadzone)
|
|
out = min(1.0, float(gain) * (t ** float(expo)))
|
|
return out if v >= 0.0 else -out
|
|
|
|
|
|
class ScreenGuidanceController:
|
|
def __init__(self):
|
|
self.enabled = bool(GUIDANCE_ENABLE)
|
|
self.export_enable = bool(self.enabled and GUIDANCE_EXPORT_ENABLE)
|
|
self.export_every = int(max(1, GUIDANCE_EXPORT_EVERY))
|
|
self.export_path = Path(GUIDANCE_EXPORT_PATH)
|
|
if self.export_enable:
|
|
self.export_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
self.cmd_keep = float(clamp(GUIDANCE_CMD_SMOOTH, 0.0, 0.97))
|
|
self.point_keep = float(clamp(GUIDANCE_POINT_SMOOTH, 0.0, 0.97))
|
|
self.last_export_frame = -10**9
|
|
self.export_retry_at = 0.0
|
|
self.last_export_warn_ts = 0.0
|
|
self.smoothed_cmd = np.zeros(2, dtype=np.float32)
|
|
self.smoothed_point = None
|
|
|
|
def status_line(self):
|
|
if not self.enabled:
|
|
return "Guidance disabled"
|
|
if self.export_enable:
|
|
return f"Guidance ready: export={self.export_path}"
|
|
return "Guidance ready"
|
|
|
|
def reset_for_target_switch(self, aim_point, cmd_damp=0.0):
|
|
aim = np.array(aim_point, dtype=np.float32)
|
|
self.smoothed_point = aim.copy()
|
|
damp = float(clamp(cmd_damp, 0.0, 1.0))
|
|
self.smoothed_cmd = (self.smoothed_cmd * damp).astype(np.float32)
|
|
|
|
def idle_state(self):
|
|
return {
|
|
"active": False,
|
|
"status": "SEARCH",
|
|
"frame_id": -1,
|
|
"target_id": None,
|
|
"frame_w": None,
|
|
"frame_h": None,
|
|
"aim_x": None,
|
|
"aim_y": None,
|
|
"box_w": None,
|
|
"box_h": None,
|
|
"error_x": 0.0,
|
|
"error_y": 0.0,
|
|
"cmd_x": 0.0,
|
|
"cmd_y": 0.0,
|
|
"steer_x": 0.0,
|
|
"steer_y": 0.0,
|
|
"look_dx": 0.0,
|
|
"look_dy": 0.0,
|
|
"confidence": 0.0,
|
|
"on_target": False,
|
|
}
|
|
|
|
def update(
|
|
self,
|
|
frame_id,
|
|
frame_w,
|
|
frame_h,
|
|
confirmed,
|
|
locked_box,
|
|
pred_box,
|
|
override_box,
|
|
target_id,
|
|
target_track_score,
|
|
klt_valid,
|
|
klt_quality,
|
|
miss_streak,
|
|
vx,
|
|
vy,
|
|
):
|
|
state = self.idle_state()
|
|
state["frame_id"] = int(frame_id)
|
|
state["target_id"] = None if target_id is None else int(target_id)
|
|
state["frame_w"] = int(frame_w)
|
|
state["frame_h"] = int(frame_h)
|
|
|
|
if not self.enabled:
|
|
return state
|
|
|
|
guide_box = None
|
|
if confirmed and override_box is not None:
|
|
guide_box = np.array(override_box, dtype=np.float32)
|
|
elif confirmed and locked_box is not None:
|
|
guide_box = np.array(locked_box, dtype=np.float32)
|
|
elif confirmed and pred_box is not None:
|
|
guide_box = np.array(pred_box, dtype=np.float32)
|
|
|
|
if guide_box is None:
|
|
self.smoothed_cmd *= 0.60
|
|
state["cmd_x"] = float(self.smoothed_cmd[0])
|
|
state["cmd_y"] = float(self.smoothed_cmd[1])
|
|
state["steer_x"] = float(self.smoothed_cmd[0])
|
|
state["steer_y"] = float(self.smoothed_cmd[1])
|
|
state["look_dx"] = float(self.smoothed_cmd[0] * float(GUIDANCE_LOOK_MAX_STEP_PX))
|
|
state["look_dy"] = float(self.smoothed_cmd[1] * float(GUIDANCE_LOOK_MAX_STEP_PX))
|
|
self._maybe_export(state)
|
|
return state
|
|
|
|
guide_box = clip_box(guide_box, frame_w, frame_h)
|
|
center = box_center(guide_box).astype(np.float32)
|
|
box_w = float(max(0.0, guide_box[2] - guide_box[0]))
|
|
box_h = float(max(0.0, guide_box[3] - guide_box[1]))
|
|
|
|
lead = np.array([float(vx), float(vy)], dtype=np.float32) * float(GUIDANCE_LEAD_SEC)
|
|
lead_norm = float(np.linalg.norm(lead))
|
|
if lead_norm > float(GUIDANCE_MAX_LEAD_PX):
|
|
lead *= float(GUIDANCE_MAX_LEAD_PX) / max(1e-6, lead_norm)
|
|
|
|
aim = center + lead
|
|
aim[1] += float(GUIDANCE_BOX_BIAS_Y) * max(2.0, float(guide_box[3] - guide_box[1]))
|
|
aim[0] = float(clamp(aim[0], 0.0, float(frame_w - 1)))
|
|
aim[1] = float(clamp(aim[1], 0.0, float(frame_h - 1)))
|
|
|
|
if self.smoothed_point is None:
|
|
self.smoothed_point = aim.copy()
|
|
else:
|
|
self.smoothed_point = (
|
|
(self.point_keep * self.smoothed_point)
|
|
+ ((1.0 - self.point_keep) * aim)
|
|
).astype(np.float32)
|
|
|
|
screen_center = np.array([0.5 * float(frame_w), 0.5 * float(frame_h)], dtype=np.float32)
|
|
error_px = self.smoothed_point - screen_center
|
|
error_norm = np.array(
|
|
[
|
|
float(error_px[0] / max(1.0, 0.5 * float(frame_w))),
|
|
float(error_px[1] / max(1.0, 0.5 * float(frame_h))),
|
|
],
|
|
dtype=np.float32,
|
|
)
|
|
error_norm = np.clip(error_norm, -1.0, 1.0)
|
|
|
|
raw_cmd = np.array(
|
|
[
|
|
_curve_command(error_norm[0], GUIDANCE_DEADZONE_NORM, GUIDANCE_EXPO, GUIDANCE_GAIN_X),
|
|
_curve_command(error_norm[1], GUIDANCE_DEADZONE_NORM, GUIDANCE_EXPO, GUIDANCE_GAIN_Y),
|
|
],
|
|
dtype=np.float32,
|
|
)
|
|
self.smoothed_cmd = (
|
|
(self.cmd_keep * self.smoothed_cmd)
|
|
+ ((1.0 - self.cmd_keep) * raw_cmd)
|
|
).astype(np.float32)
|
|
|
|
confidence = 0.0
|
|
confidence += 0.40 if confirmed else 0.0
|
|
confidence += 0.15 if locked_box is not None else 0.05
|
|
|
|
# KLT теперь главный источник доверия (klt_quality ≈ 0.93)
|
|
if klt_valid:
|
|
confidence += 0.30 * float(clamp(klt_quality, 0.0, 1.0))
|
|
else:
|
|
confidence += 0.05 * float(clamp(klt_quality, 0.0, 1.0))
|
|
|
|
# ByteTrack score — второстепенный
|
|
if target_track_score is not None:
|
|
confidence += 0.10 * float(clamp(target_track_score, 0.0, 1.0))
|
|
|
|
# Мягче штраф за miss (YOLO слабый, это временно)
|
|
confidence -= 0.025 * float(clamp(miss_streak, 0, 10))
|
|
|
|
confidence = float(clamp(confidence, 0.0, 1.0))
|
|
|
|
err_mag = float(np.linalg.norm(error_norm))
|
|
on_target = bool(
|
|
confirmed
|
|
and (confidence >= float(GUIDANCE_CONF_MIN))
|
|
and (err_mag <= float(GUIDANCE_ON_TARGET_NORM))
|
|
)
|
|
|
|
status = "SEARCH"
|
|
if confirmed and override_box is not None and miss_streak > 0:
|
|
status = "REACQ"
|
|
elif confirmed and miss_streak > 0:
|
|
status = "HOLD"
|
|
elif confirmed:
|
|
status = "LOCK"
|
|
|
|
state.update(
|
|
{
|
|
"active": bool(confirmed),
|
|
"status": status,
|
|
"aim_x": float(self.smoothed_point[0]),
|
|
"aim_y": float(self.smoothed_point[1]),
|
|
"box_w": box_w,
|
|
"box_h": box_h,
|
|
"error_x": float(error_norm[0]),
|
|
"error_y": float(error_norm[1]),
|
|
"cmd_x": float(self.smoothed_cmd[0]),
|
|
"cmd_y": float(self.smoothed_cmd[1]),
|
|
"steer_x": float(self.smoothed_cmd[0]),
|
|
"steer_y": float(self.smoothed_cmd[1]),
|
|
"look_dx": float(self.smoothed_cmd[0] * float(GUIDANCE_LOOK_MAX_STEP_PX)),
|
|
"look_dy": float(self.smoothed_cmd[1] * float(GUIDANCE_LOOK_MAX_STEP_PX)),
|
|
"confidence": confidence,
|
|
"on_target": on_target,
|
|
}
|
|
)
|
|
self._maybe_export(state)
|
|
return state
|
|
|
|
def draw_overlay(self, frame_bgr, state, sx, sy):
|
|
if (not self.enabled) or (not DRAW_GUIDANCE):
|
|
return
|
|
|
|
frame_h, frame_w = frame_bgr.shape[:2]
|
|
cx = int(0.5 * frame_w)
|
|
cy = int(0.5 * frame_h)
|
|
|
|
color = (0, 220, 255) if state.get("active", False) else (120, 120, 120)
|
|
cv2.drawMarker(frame_bgr, (cx, cy), color, markerType=cv2.MARKER_CROSS, markerSize=18, thickness=1)
|
|
|
|
aim_x = state.get("aim_x", None)
|
|
aim_y = state.get("aim_y", None)
|
|
if aim_x is not None and aim_y is not None:
|
|
ax = int(float(aim_x) / max(1e-6, float(sx)))
|
|
ay = int(float(aim_y) / max(1e-6, float(sy)))
|
|
cv2.circle(frame_bgr, (ax, ay), 8, color, 2)
|
|
cv2.line(frame_bgr, (cx, cy), (ax, ay), color, 1, cv2.LINE_AA)
|
|
cv2.putText(
|
|
frame_bgr,
|
|
f"G {state['status']} steer=({state['steer_x']:+.2f},{state['steer_y']:+.2f}) conf={state['confidence']:.2f}",
|
|
(20, 165),
|
|
cv2.FONT_HERSHEY_SIMPLEX,
|
|
0.60,
|
|
color,
|
|
2,
|
|
)
|
|
|
|
def _maybe_export(self, state):
|
|
frame_id = int(state["frame_id"])
|
|
now = time.monotonic()
|
|
if (not self.export_enable) or ((frame_id - self.last_export_frame) < self.export_every) or (now < self.export_retry_at):
|
|
return
|
|
|
|
payload = {
|
|
"frame_id": frame_id,
|
|
"active": bool(state["active"]),
|
|
"status": str(state["status"]),
|
|
"target_id": state["target_id"],
|
|
"frame_w": state["frame_w"],
|
|
"frame_h": state["frame_h"],
|
|
"aim_x": state["aim_x"],
|
|
"aim_y": state["aim_y"],
|
|
"box_w": state["box_w"],
|
|
"box_h": state["box_h"],
|
|
"error_x": float(state["error_x"]),
|
|
"error_y": float(state["error_y"]),
|
|
"cmd_x": float(state["cmd_x"]),
|
|
"cmd_y": float(state["cmd_y"]),
|
|
"steer_x": float(state["steer_x"]),
|
|
"steer_y": float(state["steer_y"]),
|
|
"look_dx": float(state["look_dx"]),
|
|
"look_dy": float(state["look_dy"]),
|
|
"confidence": float(state["confidence"]),
|
|
"on_target": bool(state["on_target"]),
|
|
}
|
|
tmp_path = self.export_path.with_suffix(self.export_path.suffix + ".tmp")
|
|
try:
|
|
with tmp_path.open("w", encoding="utf-8") as f:
|
|
json.dump(payload, f, ensure_ascii=True, indent=2)
|
|
self._replace_export_file(tmp_path)
|
|
except PermissionError as exc:
|
|
self.export_retry_at = time.monotonic() + 0.25
|
|
self._warn_export_error(frame_id, exc)
|
|
return
|
|
except OSError as exc:
|
|
self.export_retry_at = time.monotonic() + 0.50
|
|
self._warn_export_error(frame_id, exc)
|
|
return
|
|
|
|
self.export_retry_at = 0.0
|
|
self.last_export_frame = frame_id
|
|
|
|
def _replace_export_file(self, tmp_path):
|
|
last_err = None
|
|
for delay_sec in (0.0, 0.01, 0.03):
|
|
if delay_sec > 0.0:
|
|
time.sleep(delay_sec)
|
|
try:
|
|
tmp_path.replace(self.export_path)
|
|
return
|
|
except PermissionError as exc:
|
|
last_err = exc
|
|
|
|
if last_err is not None:
|
|
raise last_err
|
|
|
|
def _warn_export_error(self, frame_id, exc):
|
|
now = time.monotonic()
|
|
if (now - self.last_export_warn_ts) < 2.0:
|
|
return
|
|
print(f"[guidance] export skipped at frame {frame_id} for {self.export_path}: {exc}")
|
|
self.last_export_warn_ts = now
|