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.
MAI/trackers_hybrid.py

457 lines
19 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.

# ============================================================
# trackers_hybrid.py (v2 — affine motion for maneuvering targets)
#
# Ключевые отличия от v1:
# 1. KLT путь оценивает ПОЛНУЮ аффинку (translation + rotation
# + scale) через cv2.estimateAffinePartial2D, а не только
# медиану сдвигов. Это необходимо когда цель поворачивается
# в кадре — при rigid translation inlier-точки разлетаются
# и квалити падает.
# 2. Применение аффинки к центру и к размеру bbox с clamp'ом
# на изменение масштаба за кадр (защита от RANSAC-сбоев).
# 3. Более частая переинициализация features (8 вместо 15)
# для быстрого подстраивания под вращающуюся цель.
# 4. Phase correlation теперь работает на НЕСКОЛЬКИХ масштабах
# шаблона (0.9x, 1.0x, 1.1x) чтобы устойчиво ловить цель при
# приближении/удалении.
# ============================================================
import cv2
import numpy as np
from config import *
from helpers import clamp
# ─── Параметры ───────────────────────────────────────────────
HYBRID_CLAHE_CLIP = 2.5
HYBRID_CLAHE_TILE = 4
HYBRID_KLT_QUALITY = 0.005
HYBRID_KLT_QUALITY_RETRY = 0.002
HYBRID_KLT_MIN_DIST = 2
HYBRID_KLT_MAX_CORNERS = 50
HYBRID_KLT_MIN_INLIERS = 3
HYBRID_KLT_MIN_FOR_AFFINE = 6 # меньше — fallback на median translation
HYBRID_KLT_OUTLIER_THR = 3.5
HYBRID_PAD_FACTOR = 0.25
HYBRID_SEARCH_FACTOR = 2.4 # чуть больше search window для манёвров
HYBRID_PHASE_MIN_RESP = 0.22 # чуть ослаблен
HYBRID_TM_MIN_SCORE = 0.42
HYBRID_TM_SCALES = (0.9, 1.0, 1.1)
# Защита от RANSAC-сбоев (sanity check, размер bbox НЕ обновляется)
HYBRID_MAX_SCALE_PER_FRAME = 1.25 # допустимый диапазон нормы аффинки
HYBRID_MIN_SCALE_PER_FRAME = 0.80 # иначе считаем RANSAC сбоем
HYBRID_MAX_TRANSLATION_PX = 80.0 # максимальный сдвиг центра за кадр
HYBRID_TEMPLATE_UPDATE_EMA = 0.80 # чуть быстрее обновлять (было 0.85)
HYBRID_REINIT_FEATURES_EVERY = 8 # было 15 — для динамичной цели
class HybridTracker:
"""
Многоуровневый трекер для мелких манёвренных целей.
API:
trk = HybridTracker()
trk.init(frame_bgr, box)
new_box = trk.update(frame_bgr) # None если потеряли
Поля:
self.box — текущий bbox
self.quality — 0..1
self.last_method — "klt-affine" / "klt-translate" / "phasecorr" / "template" / "lost"
self.good_count — число inliers KLT на последнем кадре
"""
def __init__(self):
self._clahe = cv2.createCLAHE(
clipLimit=float(HYBRID_CLAHE_CLIP),
tileGridSize=(int(HYBRID_CLAHE_TILE), int(HYBRID_CLAHE_TILE)),
)
self.reset()
# ─── Public API ──────────────────────────────────────────
def reset(self):
self.prev_gray = None
self.prev_gray_eq = None
self.pts = None
self.box = None
self.template = None
self.good_count = 0
self.quality = 0.0
self.last_method = "none"
self._frames_since_feature_init = 0
self._success_streak = 0
def init(self, frame_bgr, box):
if frame_bgr is None or box is None:
return
gray = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2GRAY)
gray_eq = self._clahe.apply(gray)
self.box = np.array(box, dtype=np.float32)
self.prev_gray = gray
self.prev_gray_eq = gray_eq
self._extract_features(gray_eq, self.box)
self._update_template(gray_eq, self.box, reset=True)
self._frames_since_feature_init = 0
self._success_streak = 0
self.last_method = "init"
def update(self, frame_bgr):
if self.box is None or self.prev_gray is None:
return None
gray = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2GRAY)
gray_eq = self._clahe.apply(gray)
# ── Попытка 1: KLT (affine или fallback translate) ──
new_box_klt, q_klt, klt_method = self._try_klt(gray, gray_eq)
# ── Попытка 2: Phase correlation ────────────────────
new_box_pc, q_pc = (None, 0.0)
if new_box_klt is None or q_klt < 0.25:
new_box_pc, q_pc = self._try_phase_correlation(gray_eq)
# ── Попытка 3: Template matching (multi-scale) ──────
new_box_tm, q_tm = (None, 0.0)
if new_box_klt is None and new_box_pc is None:
new_box_tm, q_tm = self._try_template_match(gray_eq)
# ── Выбор лучшего ─────────────────────────────────────
candidates = []
if new_box_klt is not None:
candidates.append((klt_method, new_box_klt, q_klt))
if new_box_pc is not None:
candidates.append(("phasecorr", new_box_pc, q_pc))
if new_box_tm is not None:
candidates.append(("template", new_box_tm, q_tm))
if not candidates:
self.last_method = "lost"
self.quality = 0.0
self.prev_gray = gray
self.prev_gray_eq = gray_eq
return None
candidates.sort(key=lambda c: c[2], reverse=True)
method, new_box, q = candidates[0]
self.box = new_box
self.quality = float(q)
self.last_method = method
self._success_streak += 1
self._frames_since_feature_init += 1
# Переинициализация features и обновление template
if self._frames_since_feature_init >= int(HYBRID_REINIT_FEATURES_EVERY):
self._extract_features(gray_eq, self.box)
self._update_template(gray_eq, self.box, reset=False)
self._frames_since_feature_init = 0
elif method in ("phasecorr", "template") and self._success_streak % 4 == 0:
self._extract_features(gray_eq, self.box)
self._update_template(gray_eq, self.box, reset=False)
self.prev_gray = gray
self.prev_gray_eq = gray_eq
return self.box
# ─── Level 1a: KLT with AFFINE motion ────────────────────
def _try_klt(self, gray, gray_eq):
"""
Returns (new_box, quality, method_name).
method_name ∈ {"klt-affine", "klt-translate"} или (None, 0, "none").
"""
if self.pts is None or len(self.pts) == 0:
return None, 0.0, "none"
next_pts, st, err = cv2.calcOpticalFlowPyrLK(
self.prev_gray_eq, gray_eq, self.pts, None,
winSize=(21, 21),
maxLevel=3,
criteria=(cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 30, 0.01),
)
if next_pts is None or st is None:
self.good_count = 0
return None, 0.0, "none"
st = st.reshape(-1).astype(bool)
good_old = self.pts[st].reshape(-1, 2)
good_new = next_pts[st].reshape(-1, 2)
self.good_count = int(len(good_new))
if self.good_count < int(HYBRID_KLT_MIN_INLIERS):
return None, 0.0, "none"
# ── Путь А: affine через estimateAffinePartial2D (RANSAC) ──
if self.good_count >= int(HYBRID_KLT_MIN_FOR_AFFINE):
M, inlier_mask = cv2.estimateAffinePartial2D(
good_old, good_new,
method=cv2.RANSAC,
ransacReprojThreshold=float(HYBRID_KLT_OUTLIER_THR),
maxIters=200,
confidence=0.99,
)
if M is not None and inlier_mask is not None:
inlier_cnt = int(inlier_mask.sum())
if inlier_cnt >= int(HYBRID_KLT_MIN_INLIERS):
new_box = self._apply_affine_to_box(M, self.box)
if new_box is not None and self._is_sane_box_update(self.box, new_box):
# quality: fraction of inliers * абсолютное число
frac = float(inlier_cnt) / max(1, self.good_count)
quality = float(clamp(
frac * (inlier_cnt / max(6.0, HYBRID_KLT_MAX_CORNERS * 0.4)),
0.0, 1.0,
))
# Обновляем pts только inliers для следующего кадра
mask_flat = inlier_mask.reshape(-1).astype(bool)
self.pts = good_new[mask_flat].reshape(-1, 1, 2).astype(np.float32)
return new_box, quality, "klt-affine"
# ── Путь Б: fallback median translation ─────────────
disp = good_new - good_old
med = np.median(disp, axis=0)
d = np.linalg.norm(disp - med[None, :], axis=1)
inliers = d < float(HYBRID_KLT_OUTLIER_THR)
in_cnt = int(np.count_nonzero(inliers))
if in_cnt < int(HYBRID_KLT_MIN_INLIERS):
return None, 0.0, "none"
disp_in = disp[inliers]
med = np.median(disp_in, axis=0)
dx, dy = float(med[0]), float(med[1])
if abs(dx) > HYBRID_MAX_TRANSLATION_PX or abs(dy) > HYBRID_MAX_TRANSLATION_PX:
return None, 0.0, "none"
x1, y1, x2, y2 = self.box
new_box = np.array([x1 + dx, y1 + dy, x2 + dx, y2 + dy], dtype=np.float32)
frac = float(in_cnt) / max(1, self.good_count)
quality = float(clamp(
frac * (in_cnt / max(6.0, HYBRID_KLT_MAX_CORNERS * 0.5)),
0.0, 0.85, # translate даёт меньший макс quality чем affine
))
self.pts = good_new[inliers].reshape(-1, 1, 2).astype(np.float32)
return new_box, quality, "klt-translate"
def _apply_affine_to_box(self, M, box):
"""
Применяет аффинку ТОЛЬКО к центру bbox (translation + rotation).
РАЗМЕР bbox НЕ ОБНОВЛЯЕТСЯ — остаётся фиксированным.
Причина: когда KLT-трекер масштабировал бокс своим scale, он
постепенно расходился по размеру с тем, что детектирует YOLO,
в результате ByteTrack не ассоциировал детекты с треком
(hit_streak падал до 0). Размер бокса должен обновляться
только от YOLO — это единственный надёжный источник истинного
масштаба цели.
scale от аффинки используется только для sanity check: если
он слишком далёк от 1.0, значит RANSAC сбился, возвращаем None.
"""
x1, y1, x2, y2 = [float(v) for v in box]
cx = 0.5 * (x1 + x2)
cy = 0.5 * (y1 + y2)
w = x2 - x1
h = y2 - y1
# Центр через аффинку
new_cx = M[0, 0] * cx + M[0, 1] * cy + M[0, 2]
new_cy = M[1, 0] * cx + M[1, 1] * cy + M[1, 2]
# Sanity check через норму столбца (должна быть ~1.0 для
# кадра к кадру; большое отклонение = RANSAC сбой)
scale = float(np.hypot(M[0, 0], M[1, 0]))
if not (float(HYBRID_MIN_SCALE_PER_FRAME) <= scale <= float(HYBRID_MAX_SCALE_PER_FRAME)):
return None
# Размер НЕ меняем — используем старые w, h
return np.array(
[new_cx - w * 0.5, new_cy - h * 0.5,
new_cx + w * 0.5, new_cy + h * 0.5],
dtype=np.float32,
)
def _is_sane_box_update(self, old_box, new_box):
"""Защита от RANSAC-выбросов."""
ox1, oy1, ox2, oy2 = [float(v) for v in old_box]
nx1, ny1, nx2, ny2 = [float(v) for v in new_box]
ocx = 0.5 * (ox1 + ox2)
ocy = 0.5 * (oy1 + oy2)
ncx = 0.5 * (nx1 + nx2)
ncy = 0.5 * (ny1 + ny2)
shift = float(np.hypot(ncx - ocx, ncy - ocy))
if shift > float(HYBRID_MAX_TRANSLATION_PX):
return False
return True
# ─── Level 2: Phase correlation ──────────────────────────
def _try_phase_correlation(self, gray_eq):
if self.template is None or self.template.size == 0:
return None, 0.0
search, (sx, sy) = self._crop_search_window(gray_eq, self.box)
if search is None:
return None, 0.0
h_t, w_t = self.template.shape[:2]
h_s, w_s = search.shape[:2]
if h_s < h_t or w_s < w_t:
return None, 0.0
cy_s = h_s // 2
cx_s = w_s // 2
y0 = max(0, cy_s - h_t // 2)
x0 = max(0, cx_s - w_t // 2)
y1 = min(h_s, y0 + h_t)
x1 = min(w_s, x0 + w_t)
patch = search[y0:y1, x0:x1]
if patch.shape != self.template.shape:
patch = cv2.copyMakeBorder(
patch, 0, h_t - patch.shape[0], 0, w_t - patch.shape[1],
cv2.BORDER_REPLICATE,
)
try:
t_f = self.template.astype(np.float32)
p_f = patch.astype(np.float32)
(shift_x, shift_y), response = cv2.phaseCorrelate(t_f, p_f)
except cv2.error:
return None, 0.0
if response < float(HYBRID_PHASE_MIN_RESP):
return None, 0.0
if abs(shift_x) > HYBRID_MAX_TRANSLATION_PX or abs(shift_y) > HYBRID_MAX_TRANSLATION_PX:
return None, 0.0
x1b, y1b, x2b, y2b = self.box
new_box = np.array(
[x1b + shift_x, y1b + shift_y, x2b + shift_x, y2b + shift_y],
dtype=np.float32,
)
quality = float(clamp(response * 0.75, 0.0, 0.75))
return new_box, quality
# ─── Level 3: Multi-scale template matching ──────────────
def _try_template_match(self, gray_eq):
if self.template is None or self.template.size == 0:
return None, 0.0
search, (sx, sy) = self._crop_search_window(gray_eq, self.box)
if search is None:
return None, 0.0
best = None # (score, new_box)
for scale in HYBRID_TM_SCALES:
if scale == 1.0:
tmpl = self.template
else:
nh = max(4, int(self.template.shape[0] * scale))
nw = max(4, int(self.template.shape[1] * scale))
tmpl = cv2.resize(self.template, (nw, nh), interpolation=cv2.INTER_LINEAR)
h_t, w_t = tmpl.shape[:2]
if search.shape[0] < h_t or search.shape[1] < w_t:
continue
try:
result = cv2.matchTemplate(search, tmpl, cv2.TM_CCOEFF_NORMED)
min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result)
except cv2.error:
continue
if max_val < float(HYBRID_TM_MIN_SCORE):
continue
mx, my = max_loc
nx1 = sx + mx
ny1 = sy + my
new_box = np.array(
[nx1, ny1, nx1 + w_t, ny1 + h_t], dtype=np.float32
)
if best is None or max_val > best[0]:
best = (float(max_val), new_box)
if best is None:
return None, 0.0
score, new_box = best
quality = float(clamp(score * 0.72, 0.0, 0.72))
return new_box, quality
# ─── Helpers ─────────────────────────────────────────────
def _extract_features(self, gray_eq, box):
h, w = gray_eq.shape[:2]
x1, y1, x2, y2 = box
bw = max(1.0, x2 - x1)
bh = max(1.0, y2 - y1)
pad_x = bw * float(HYBRID_PAD_FACTOR)
pad_y = bh * float(HYBRID_PAD_FACTOR)
mx1 = int(clamp(x1 - pad_x, 0, w - 1))
my1 = int(clamp(y1 - pad_y, 0, h - 1))
mx2 = int(clamp(x2 + pad_x, 1, w))
my2 = int(clamp(y2 + pad_y, 1, h))
mask = np.zeros_like(gray_eq)
mask[my1:my2, mx1:mx2] = 255
pts = cv2.goodFeaturesToTrack(
gray_eq,
maxCorners=int(HYBRID_KLT_MAX_CORNERS),
qualityLevel=float(HYBRID_KLT_QUALITY),
minDistance=int(HYBRID_KLT_MIN_DIST),
mask=mask,
)
if pts is None or len(pts) < 6:
pts = cv2.goodFeaturesToTrack(
gray_eq,
maxCorners=int(HYBRID_KLT_MAX_CORNERS),
qualityLevel=float(HYBRID_KLT_QUALITY_RETRY),
minDistance=int(HYBRID_KLT_MIN_DIST),
mask=mask,
)
self.pts = pts.astype(np.float32) if pts is not None else None
def _update_template(self, gray_eq, box, reset=False):
h, w = gray_eq.shape[:2]
x1 = int(clamp(box[0], 0, w - 1))
y1 = int(clamp(box[1], 0, h - 1))
x2 = int(clamp(box[2], x1 + 2, w))
y2 = int(clamp(box[3], y1 + 2, h))
crop = gray_eq[y1:y2, x1:x2]
if crop.size == 0:
return
if reset or self.template is None or self.template.shape != crop.shape:
self.template = crop.copy()
else:
a = float(HYBRID_TEMPLATE_UPDATE_EMA)
self.template = (a * self.template.astype(np.float32)
+ (1.0 - a) * crop.astype(np.float32)).astype(np.uint8)
def _crop_search_window(self, gray_eq, box):
h, w = gray_eq.shape[:2]
cx = 0.5 * (float(box[0]) + float(box[2]))
cy = 0.5 * (float(box[1]) + float(box[3]))
bw = max(8.0, float(box[2] - box[0]))
bh = max(8.0, float(box[3] - box[1]))
sw = bw * float(HYBRID_SEARCH_FACTOR)
sh = bh * float(HYBRID_SEARCH_FACTOR)
sx = int(clamp(cx - sw * 0.5, 0, w - 1))
sy = int(clamp(cy - sh * 0.5, 0, h - 1))
ex = int(clamp(cx + sw * 0.5, sx + 2, w))
ey = int(clamp(cy + sh * 0.5, sy + 2, h))
search = gray_eq[sy:ey, sx:ex]
if search.size == 0:
return None, (0, 0)
return search, (sx, sy)