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.

727 lines
29 KiB
Python

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# ============================================================
# imu_fusion.py
# Sensor Fusion: IMU + Camera + Latency Compensation
#
# Решает три критических проблемы реального перехвата:
#
# 1) ЛАТЕНТНОСТЬ: между захватом кадра и моментом когда команда
# дойдёт до моторов проходит 50-120ms. При Vc=20 м/с это
# 1-2.4 метра промаха. Этот модуль экстраполирует состояние
# цели ВПЕРЁД на величину латентности.
#
# 2) СОБСТВЕННОЕ ДВИЖЕНИЕ: без IMU невозможно отличить движение
# цели от движения камеры. Optical flow affine — грубое
# приближение, ломается при тряске и быстрых манёврах.
# IMU даёт точную ориентацию и скорость перехватчика.
#
# 3) ПРЕДСКАЗАНИЕ ТОЧКИ ВСТРЕЧИ: с IMU мы знаем свою скорость,
# а с latency compensation — где цель БУДЕТ, а не где она
# БЫЛА. Это позволяет PN работать корректно.
#
# Поддерживает:
# - MAVLink ATTITUDE + LOCAL_POSITION_NED (ArduPilot/PX4)
# - MSP_ATTITUDE + MSP_RAW_IMU (Betaflight/INAV)
# - Fallback без IMU (только латентность, по Kalman-предсказанию)
# ============================================================
import time
import math
import threading
from collections import deque
import numpy as np
from config import *
from config_intercept import *
from helpers import clamp, box_center, box_wh, clip_box
# ─────────────────────────────────────────────────────────────
# Config (добавить в config_intercept.py)
# ─────────────────────────────────────────────────────────────
# Латентность пайплайна (секунды).
# Измеряется как: время захвата кадра → момент исполнения команды мотором.
# Типичные значения:
# Camera capture + USB: 15-25ms
# YOLO inference (async): 8-15ms (но результат приходит на 1-2 кадра позже)
# ByteTrack + Kalman: <1ms
# Guidance compute: <1ms
# Serial/MAVLink send: 1-5ms
# FC processing: 5-15ms
# ESC + motor response: 10-20ms
# ИТОГО: ~50-80ms для ROI, ~80-120ms для fullscan
LATENCY_FIXED_MS = 65.0 # фиксированная оценка полной латентности
LATENCY_ADAPTIVE = True # адаптивная оценка (подстраивается)
LATENCY_EXTRA_TERMINAL_MS = 0.0 # доп. латентность в TERMINAL (0 = не добавлять)
LATENCY_MAX_MS = 200.0 # верхний предел (защита от багов)
LATENCY_MIN_MS = 20.0 # нижний предел
# IMU
IMU_ENABLE = True
IMU_SOURCE = "mavlink" # "mavlink" | "msp" | "none"
IMU_MAVLINK_CONNECTION = "udpin:0.0.0.0:14550" # получаем телеметрию
IMU_MSP_PORT = "/dev/ttyUSB0"
IMU_MSP_BAUD = 115200
IMU_POLL_HZ = 100.0 # частота опроса IMU
# Сглаживание IMU
IMU_SMOOTH_ALPHA = 0.30 # EMA для скоростей
IMU_GRAVITY = 9.81
# Camera-IMU alignment (поворот IMU относительно камеры)
# Для типичного FPV: камера наклонена вперёд на 25-35°
CAMERA_TILT_DEG = 30.0 # наклон камеры вперёд (градусы)
# Latency measurement (для адаптивной оценки)
LATENCY_MEASURE_ENABLE = True
LATENCY_MEASURE_WINDOW = 30 # окно усреднения
# Debug
DRAW_LATENCY_COMP = True
DRAW_IMU_STATE = True
class IMUState:
"""Текущее состояние IMU перехватчика."""
__slots__ = [
"timestamp",
"roll", "pitch", "yaw", # радианы
"roll_rate", "pitch_rate", "yaw_rate", # рад/с
"vx", "vy", "vz", # м/с, NED frame
"ax", "ay", "az", # м/с², body frame
"lat", "lon", "alt", # GPS (если есть)
"valid",
]
def __init__(self):
self.timestamp = 0.0
self.roll = 0.0
self.pitch = 0.0
self.yaw = 0.0
self.roll_rate = 0.0
self.pitch_rate = 0.0
self.yaw_rate = 0.0
self.vx = 0.0
self.vy = 0.0
self.vz = 0.0
self.ax = 0.0
self.ay = 0.0
self.az = 0.0
self.lat = 0.0
self.lon = 0.0
self.alt = 0.0
self.valid = False
class LatencyCompensator:
"""
Компенсация латентности пайплайна.
Принцип: вместо того чтобы наводиться на позицию цели "сейчас"
(которая на самом деле была latency_ms миллисекунд назад),
экстраполируем состояние цели вперёд на величину латентности.
Использует:
- Kalman/IMM-предсказание для экстраполяции цели
- IMU для вычитания собственного движения
- Адаптивную оценку латентности по корреляции предсказание/факт
"""
def __init__(self):
self.latency_sec = float(LATENCY_FIXED_MS) / 1000.0
self.adaptive = bool(LATENCY_ADAPTIVE)
# Адаптивная оценка латентности
self._pred_hist = deque(maxlen=int(LATENCY_MEASURE_WINDOW))
self._measure_enable = bool(LATENCY_MEASURE_ENABLE)
# Статистика
self.compensated_shift_px = 0.0
self.ego_shift_px = 0.0
def get_latency_sec(self, phase="TRACK"):
"""Возвращает текущую оценку латентности."""
lat = self.latency_sec
if phase == "TERMINAL":
lat += float(LATENCY_EXTRA_TERMINAL_MS) / 1000.0
return float(clamp(lat,
float(LATENCY_MIN_MS) / 1000.0,
float(LATENCY_MAX_MS) / 1000.0))
def compensate(self, kf_state, imu_state, frame_w, frame_h, phase="TRACK"):
"""
Компенсирует латентность — предсказывает где цель БУДЕТ
в момент исполнения команды.
Args:
kf_state: dict с полями из Kalman/IMM:
cx, cy — текущий центр цели (пиксели)
vx, vy — скорость цели (пиксели/сек)
w, h — размер bbox
ax, ay — ускорение (если IMM CA)
imu_state: IMUState или None
frame_w, frame_h: размеры кадра
phase: текущая фаза FSM
Returns:
dict:
comp_cx, comp_cy — компенсированный центр цели
comp_box — компенсированный bbox [x1,y1,x2,y2]
ego_dx, ego_dy — смещение из-за собственного движения
pred_dx, pred_dy — смещение из-за движения цели
latency_sec — использованная латентность
shift_px — полное смещение (пиксели)
"""
lat = self.get_latency_sec(phase)
cx = float(kf_state.get("cx", frame_w * 0.5))
cy = float(kf_state.get("cy", frame_h * 0.5))
vx = float(kf_state.get("vx", 0.0))
vy = float(kf_state.get("vy", 0.0))
ax = float(kf_state.get("ax", 0.0))
ay = float(kf_state.get("ay", 0.0))
bw = float(kf_state.get("w", 20.0))
bh = float(kf_state.get("h", 20.0))
# ─── 1. Предсказание движения цели ──────────────────────
# Линейная экстраполяция + ускорение (если есть)
pred_dx = vx * lat + 0.5 * ax * lat * lat
pred_dy = vy * lat + 0.5 * ay * lat * lat
# ─── 2. Вычитание собственного движения ─────────────────
ego_dx = 0.0
ego_dy = 0.0
if imu_state is not None and imu_state.valid:
ego_dx, ego_dy = self._compute_ego_shift(
imu_state, lat, frame_w, frame_h
)
# ─── 3. Компенсированная позиция ────────────────────────
comp_cx = cx + pred_dx - ego_dx
comp_cy = cy + pred_dy - ego_dy
# Clamp к границам кадра
comp_cx = float(clamp(comp_cx, 0.0, float(frame_w - 1)))
comp_cy = float(clamp(comp_cy, 0.0, float(frame_h - 1)))
# Компенсированный bbox
comp_box = np.array([
comp_cx - bw * 0.5,
comp_cy - bh * 0.5,
comp_cx + bw * 0.5,
comp_cy + bh * 0.5,
], dtype=np.float32)
comp_box = clip_box(comp_box, frame_w, frame_h)
shift = float(np.hypot(pred_dx - ego_dx, pred_dy - ego_dy))
self.compensated_shift_px = shift
self.ego_shift_px = float(np.hypot(ego_dx, ego_dy))
return {
"comp_cx": float(comp_cx),
"comp_cy": float(comp_cy),
"comp_box": comp_box,
"ego_dx": float(ego_dx),
"ego_dy": float(ego_dy),
"pred_dx": float(pred_dx),
"pred_dy": float(pred_dy),
"latency_sec": float(lat),
"shift_px": float(shift),
}
def feed_measurement(self, predicted_center, actual_center, dt):
"""
Для адаптивной оценки латентности.
Вызывается когда YOLO даёт новую детекцию — сравниваем
где мы предсказывали цель vs где она реально оказалась.
Если prediction overshoots — латентность завышена.
Если undershoots — занижена.
"""
if not self._measure_enable or not self.adaptive:
return
if predicted_center is None or actual_center is None:
return
pred = np.array(predicted_center, dtype=np.float32)
actual = np.array(actual_center, dtype=np.float32)
error = float(np.linalg.norm(pred - actual))
self._pred_hist.append({
"error": error,
"dt": float(dt),
})
# Пока простая эвристика: если средняя ошибка растёт,
# уменьшаем латентность (overshooting)
if len(self._pred_hist) >= 10:
errors = [p["error"] for p in self._pred_hist]
recent = np.mean(errors[-5:])
older = np.mean(errors[:5])
if recent > older * 1.3:
self.latency_sec *= 0.95
elif recent < older * 0.7:
self.latency_sec *= 1.05
self.latency_sec = float(clamp(
self.latency_sec,
float(LATENCY_MIN_MS) / 1000.0,
float(LATENCY_MAX_MS) / 1000.0,
))
def _compute_ego_shift(self, imu, lat, frame_w, frame_h):
"""
Вычисляет смещение изображения из-за собственного вращения/движения
дрона-перехватчика за время lat секунд.
Используем угловые скорости IMU → пиксельное смещение через
фокусное расстояние камеры.
"""
focal = float(CAMERA_FOCAL_LENGTH_PX)
if focal < 10.0:
return 0.0, 0.0
tilt_rad = float(CAMERA_TILT_DEG) * (math.pi / 180.0)
# Угловые скорости (body frame → camera frame)
# Камера наклонена вперёд, поэтому rotation mapping:
# camera_pan ≈ yaw_rate * cos(tilt) + pitch_rate * sin(tilt)
# camera_tilt ≈ pitch_rate * cos(tilt) - yaw_rate * sin(tilt)
cos_t = math.cos(tilt_rad)
sin_t = math.sin(tilt_rad)
# Угловая скорость камеры
cam_pan_rate = imu.yaw_rate * cos_t + imu.pitch_rate * sin_t
cam_tilt_rate = imu.pitch_rate * cos_t - imu.yaw_rate * sin_t
cam_roll_rate = imu.roll_rate
# Angular velocity → pixel shift
# Для pinhole camera: dx_px ≈ focal * d_angle
ego_dx = focal * cam_pan_rate * lat # горизонтальное смещение
ego_dy = focal * cam_tilt_rate * lat # вертикальное смещение
# Roll создаёт вращение вокруг центра — для малых углов:
# dx_roll ≈ -(y - cy) * roll_rate * lat
# dy_roll ≈ (x - cx) * roll_rate * lat
# Это применяется к конкретной точке, здесь пропускаем
# (применится в compensate() если нужно)
return float(ego_dx), float(ego_dy)
class IMUReader:
"""
Асинхронное чтение IMU с полётного контроллера.
Работает в отдельном потоке.
"""
def __init__(self):
self.enabled = bool(IMU_ENABLE) and (IMU_SOURCE != "none")
self.source = str(IMU_SOURCE).lower()
self.state = IMUState()
self._lock = threading.Lock()
self._running = False
self._thread = None
self._conn = None
# Сглаживание
self._alpha = float(IMU_SMOOTH_ALPHA)
self._smooth_vx = 0.0
self._smooth_vy = 0.0
self._smooth_vz = 0.0
def start(self):
if not self.enabled:
return
self._running = True
self._thread = threading.Thread(target=self._loop, daemon=True)
self._thread.start()
def stop(self):
self._running = False
if self._thread is not None:
self._thread.join(timeout=1.0)
if self._conn is not None:
try:
self._conn.close()
except Exception:
pass
def get_state(self):
"""Потокобезопасное чтение последнего состояния IMU."""
with self._lock:
s = IMUState()
for attr in IMUState.__slots__:
setattr(s, attr, getattr(self.state, attr))
return s
def status_line(self):
if not self.enabled:
return "IMU disabled"
if self.state.valid:
return (f"IMU ready: {self.source} "
f"rpy=({math.degrees(self.state.roll):.1f}, "
f"{math.degrees(self.state.pitch):.1f}, "
f"{math.degrees(self.state.yaw):.1f})")
return f"IMU not ready: {self.source}"
def _loop(self):
"""Главный цикл чтения IMU."""
if self.source == "mavlink":
self._loop_mavlink()
elif self.source == "msp":
self._loop_msp()
def _loop_mavlink(self):
"""Чтение через MAVLink."""
try:
from pymavlink import mavutil
self._conn = mavutil.mavlink_connection(
str(IMU_MAVLINK_CONNECTION),
baud=115200,
)
except Exception as e:
print(f"[imu] MAVLink connection failed: {e}")
return
poll_interval = 1.0 / max(1.0, float(IMU_POLL_HZ))
while self._running:
try:
# Читаем ATTITUDE
msg = self._conn.recv_match(
type=["ATTITUDE", "LOCAL_POSITION_NED",
"SCALED_IMU2", "HIGHRES_IMU"],
blocking=True,
timeout=poll_interval,
)
if msg is None:
continue
with self._lock:
msg_type = msg.get_type()
if msg_type == "ATTITUDE":
self.state.roll = float(msg.roll)
self.state.pitch = float(msg.pitch)
self.state.yaw = float(msg.yaw)
self.state.roll_rate = float(msg.rollspeed)
self.state.pitch_rate = float(msg.pitchspeed)
self.state.yaw_rate = float(msg.yawspeed)
self.state.timestamp = time.perf_counter()
self.state.valid = True
elif msg_type == "LOCAL_POSITION_NED":
raw_vx = float(msg.vx)
raw_vy = float(msg.vy)
raw_vz = float(msg.vz)
a = self._alpha
self._smooth_vx = a * self._smooth_vx + (1 - a) * raw_vx
self._smooth_vy = a * self._smooth_vy + (1 - a) * raw_vy
self._smooth_vz = a * self._smooth_vz + (1 - a) * raw_vz
self.state.vx = self._smooth_vx
self.state.vy = self._smooth_vy
self.state.vz = self._smooth_vz
elif msg_type in ("SCALED_IMU2", "HIGHRES_IMU"):
if hasattr(msg, "xacc"):
self.state.ax = float(msg.xacc) / 1000.0 * IMU_GRAVITY
self.state.ay = float(msg.yacc) / 1000.0 * IMU_GRAVITY
self.state.az = float(msg.zacc) / 1000.0 * IMU_GRAVITY
except Exception:
time.sleep(poll_interval)
def _loop_msp(self):
"""Чтение через MSP (Betaflight/INAV)."""
try:
import serial
self._conn = serial.Serial(
str(IMU_MSP_PORT),
int(IMU_MSP_BAUD),
timeout=0.02,
)
except Exception as e:
print(f"[imu] MSP serial open failed: {e}")
return
poll_interval = 1.0 / max(1.0, float(IMU_POLL_HZ))
while self._running:
try:
# Запрашиваем MSP_ATTITUDE (108)
attitude = self._msp_request(108, 6)
if attitude is not None and len(attitude) >= 6:
roll_deci = int.from_bytes(attitude[0:2], 'little', signed=True)
pitch_deci = int.from_bytes(attitude[2:4], 'little', signed=True)
yaw_deg = int.from_bytes(attitude[4:6], 'little', signed=False)
with self._lock:
self.state.roll = float(roll_deci) / 10.0 * (math.pi / 180.0)
self.state.pitch = float(pitch_deci) / 10.0 * (math.pi / 180.0)
self.state.yaw = float(yaw_deg) * (math.pi / 180.0)
self.state.timestamp = time.perf_counter()
self.state.valid = True
# Запрашиваем MSP_RAW_IMU (102) для угловых скоростей
raw_imu = self._msp_request(102, 18)
if raw_imu is not None and len(raw_imu) >= 18:
gx = int.from_bytes(raw_imu[6:8], 'little', signed=True)
gy = int.from_bytes(raw_imu[8:10], 'little', signed=True)
gz = int.from_bytes(raw_imu[10:12], 'little', signed=True)
# Betaflight gyro: raw → °/s зависит от настройки
# Типично: raw / 16.4 для ±2000°/s
scale = math.pi / (180.0 * 16.4)
with self._lock:
self.state.roll_rate = float(gx) * scale
self.state.pitch_rate = float(gy) * scale
self.state.yaw_rate = float(gz) * scale
time.sleep(poll_interval)
except Exception:
time.sleep(poll_interval)
def _msp_request(self, cmd, expected_len):
"""
Отправляет MSP запрос и читает ответ.
Формат MSP v1: $M< [0] [cmd] [checksum]
Ответ: $M> [size] [cmd] [data...] [checksum]
"""
if self._conn is None:
return None
# Запрос (пустой payload)
checksum = 0 ^ cmd
packet = bytearray([
ord('$'), ord('M'), ord('<'),
0, # size = 0
cmd,
checksum & 0xFF,
])
self._conn.write(packet)
self._conn.flush()
# Чтение ответа
header = self._conn.read(5) # $M> size cmd
if len(header) < 5:
return None
if header[0:3] != b'$M>':
return None
size = header[3]
cmd_resp = header[4]
if size < expected_len:
# Читаем что есть + checksum
data = self._conn.read(size + 1)
return data[:size] if len(data) >= size else None
data = self._conn.read(size + 1) # data + checksum
if len(data) < size:
return None
return data[:size]
class SensorFusion:
"""
Высокоуровневый модуль: объединяет IMU + Latency Compensation.
Вызывается один раз в каждом кадре main loop.
Принимает текущее состояние трекера и выдаёт
компенсированную позицию/bbox для наведения.
"""
def __init__(self):
self.imu_reader = IMUReader()
self.latency_comp = LatencyCompensator()
self.enabled = True
def start(self):
self.imu_reader.start()
def stop(self):
self.imu_reader.stop()
def status_line(self):
imu_status = self.imu_reader.status_line()
lat = self.latency_comp.latency_sec * 1000.0
return f"Sensor fusion: latency={lat:.0f}ms | {imu_status}"
def update(self, kf, locked_box, pred_box, frame_w, frame_h,
phase="TRACK", confirmed=False):
"""
Полный пайплайн компенсации.
Args:
kf: Kalman8D или IMMFilter (с .x, .initialized)
locked_box: текущий locked bbox или None
pred_box: Kalman-предсказанный bbox или None
frame_w, frame_h: размеры effective frame
phase: фаза FSM
confirmed: цель подтверждена
Returns:
dict:
comp_center — компенсированный центр [cx, cy]
comp_box — компенсированный bbox [x1, y1, x2, y2]
raw_center — исходный центр (без компенсации)
imu_valid — IMU доступен
latency_ms — использованная латентность
shift_px — смещение компенсации
ego_speed — скорость дрона (м/с)
ego_yaw_rate — скорость рыскания (°/с)
"""
result = {
"comp_center": None,
"comp_box": None,
"raw_center": None,
"imu_valid": False,
"latency_ms": 0.0,
"shift_px": 0.0,
"ego_speed": 0.0,
"ego_yaw_rate": 0.0,
}
# Определяем исходную позицию цели
ref_box = locked_box if locked_box is not None else pred_box
if ref_box is None or not kf.initialized:
return result
center = box_center(ref_box)
w, h = box_wh(ref_box)
result["raw_center"] = center.copy()
# Состояние из Kalman/IMM
kf_state = {
"cx": float(center[0]),
"cy": float(center[1]),
"vx": float(kf.x[4, 0]),
"vy": float(kf.x[5, 0]),
"w": float(w),
"h": float(h),
"ax": 0.0,
"ay": 0.0,
}
# Если IMM — берём ускорение из CA-модели
if hasattr(kf, 'get_acceleration'):
ax, ay = kf.get_acceleration()
kf_state["ax"] = ax
kf_state["ay"] = ay
# Читаем IMU
imu_state = self.imu_reader.get_state() if self.imu_reader.enabled else None
result["imu_valid"] = (imu_state is not None and imu_state.valid)
if imu_state is not None and imu_state.valid:
speed = float(np.hypot(imu_state.vx, imu_state.vy))
result["ego_speed"] = speed
result["ego_yaw_rate"] = float(
math.degrees(imu_state.yaw_rate)
)
# Компенсация латентности
comp = self.latency_comp.compensate(
kf_state, imu_state, frame_w, frame_h, phase
)
result["comp_center"] = np.array(
[comp["comp_cx"], comp["comp_cy"]], dtype=np.float32
)
result["comp_box"] = comp["comp_box"]
result["latency_ms"] = comp["latency_sec"] * 1000.0
result["shift_px"] = comp["shift_px"]
return result
def feed_detection(self, predicted_center, actual_center, dt):
"""Для адаптивной калибровки латентности."""
self.latency_comp.feed_measurement(predicted_center, actual_center, dt)
def draw_overlay(self, frame_bgr, result, sx, sy):
"""Визуализация компенсации."""
if not DRAW_LATENCY_COMP:
return
import cv2
raw = result.get("raw_center")
comp_center = result.get("comp_center")
shift = result.get("shift_px", 0.0)
lat = result.get("latency_ms", 0.0)
if raw is not None and comp_center is not None:
# Линия от raw к compensated
rx = int(float(raw[0]) / max(1e-6, sx))
ry = int(float(raw[1]) / max(1e-6, sy))
cx = int(float(comp_center[0]) / max(1e-6, sx))
cy = int(float(comp_center[1]) / max(1e-6, sy))
color = (255, 128, 0) # оранжевый
cv2.arrowedLine(frame_bgr, (rx, ry), (cx, cy),
color, 2, cv2.LINE_AA)
cv2.circle(frame_bgr, (cx, cy), 5, color, -1)
# Текст
y0 = 320
imu_str = "IMU:OK" if result.get("imu_valid") else "IMU:---"
ego = result.get("ego_speed", 0.0)
yaw_r = result.get("ego_yaw_rate", 0.0)
txt = f"LAT: {lat:.0f}ms shift={shift:.1f}px {imu_str}"
cv2.putText(frame_bgr, txt, (20, y0),
cv2.FONT_HERSHEY_SIMPLEX, 0.55, (255, 128, 0), 2)
if DRAW_IMU_STATE and result.get("imu_valid"):
txt2 = f"EGO: v={ego:.1f}m/s yaw_r={yaw_r:.1f}d/s"
cv2.putText(frame_bgr, txt2, (20, y0 + 25),
cv2.FONT_HERSHEY_SIMPLEX, 0.55, (255, 128, 0), 2)
# =============================================================
# ИНТЕГРАЦИЯ В MAIN.PY
# =============================================================
#
# 1. Импорт:
# from imu_fusion import SensorFusion
#
# 2. Инициализация (после guidance_ctrl):
# sensor_fusion = SensorFusion()
# sensor_fusion.start()
# print(sensor_fusion.status_line())
#
# 3. В главном цикле, ПОСЛЕ guidance_state и ПЕРЕД autopilot:
#
# fusion_result = sensor_fusion.update(
# kf=kf,
# locked_box=locked_box_eff,
# pred_box=pred_box_eff,
# frame_w=ew,
# frame_h=eh,
# phase=intercept_params.get("phase", "TRACK") if intercept_params else "TRACK",
# confirmed=confirmed,
# )
#
# # Используем компенсированную позицию для PN и autopilot:
# if fusion_result["comp_center"] is not None:
# # Передаём в PN вместо raw center
# target_center_for_pn = fusion_result["comp_center"]
# target_box_for_guidance = fusion_result["comp_box"]
#
# 4. При получении новой YOLO-детекции — адаптивная калибровка:
# if have_yolo and chosen_valid:
# sensor_fusion.feed_detection(
# predicted_center=box_center(pred_box_eff) if pred_box_eff is not None else None,
# actual_center=box_center(chosen),
# dt=dt,
# )
#
# 5. Отрисовка:
# sensor_fusion.draw_overlay(frame_orig, fusion_result, sx, sy)
#
# 6. Cleanup:
# sensor_fusion.stop()
# =============================================================