# ============================================================ # motion_saliency.py # Разделение независимого движения и ego-motion камеры. # # Поддерживает ОБА типа ego-motion: # - affine 2x3 (через cv2.warpAffine) # - homography 3x3 (через cv2.warpPerspective) # # Кроме того поддерживает режим "dual residual": считается # residual от обеих моделей одновременно, и в каждой точке # берётся МИНИМУМ — если хотя бы одна модель объясняет движение # пикселя, он считается статичным. Устойчиво к вырождению любой # из моделей на сложных сценах. # ============================================================ import cv2 import numpy as np from config import * from helpers import clamp, clip_box # ───────────────────────────────────────────────────────────── # Параметры # ───────────────────────────────────────────────────────────── MS_ENABLE = True MS_BLUR_KSIZE = 5 MS_DIFF_THR = 18 MS_MORPH_OPEN_KSIZE = 3 MS_MORPH_CLOSE_KSIZE = 5 MS_EMA_ALPHA = 0.55 MS_OUTLIER_SIGMA_PX = 18.0 MS_OUTLIER_WEIGHT = 0.55 MS_DIFF_WEIGHT = 0.45 MS_MIN_WARP_SHIFT_PX = 0.3 MS_MASK_TOP_FRAC = 0.0 MS_MASK_BOTTOM_FRAC = 0.0 MS_DRAW_OVERLAY = True MS_DRAW_ALPHA = 0.35 # Dual-residual mode: если True, motion_sal.update_dual() считает # обе карты и берёт min — нужно если включаете и homography, и # affine параллельно (устойчивее к вырождению). MS_DUAL_MODE_DEFAULT = False def _warp_prev(prev_gray, M, kind, out_size): """Универсальный варп предыдущего кадра под текущий.""" w, h = out_size if M is None or kind == "none": return prev_gray if kind == "affine": tx = float(M[0, 2]) ty = float(M[1, 2]) if abs(tx) + abs(ty) < float(MS_MIN_WARP_SHIFT_PX): return prev_gray return cv2.warpAffine( prev_gray, M, (w, h), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE, ) if kind == "homography": return cv2.warpPerspective( prev_gray, M, (w, h), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE, ) return prev_gray class MotionSaliency: """ Карта независимого движения на кадре. Базовое использование: ms = MotionSaliency() ms.update(gray, prev_gray, M, kind="homography", outlier_pts=pts) score = ms.score_box(box) rois = ms.get_rois(thr=0.35) Dual-residual (устойчивый на сложных сценах): ms.update_dual(gray, prev_gray, H=homography, A=affine, outlier_pts=pts) """ def __init__(self): self.enabled = bool(MS_ENABLE) self.saliency = None self.raw_diff = None self.outlier_density = None self.last_active_frac = 0.0 self.last_kind = "none" self._ema_alpha = float(MS_EMA_ALPHA) def reset(self): self.saliency = None self.raw_diff = None self.outlier_density = None self.last_active_frac = 0.0 self.last_kind = "none" # ─── Основной update ───────────────────────────────────── def update(self, gray, prev_gray, M, kind="affine", outlier_pts=None): """ M — матрица ego-motion (2x3 для affine, 3x3 для homography) kind — "affine" | "homography" | "none" """ if not self.enabled or gray is None: return None h, w = gray.shape[:2] self.last_kind = kind diff_map = self._compute_diff_map(gray, prev_gray, M, kind) outlier_map = self._compute_outlier_density(outlier_pts, h, w) combined = self._combine(diff_map, outlier_map, h, w) self._apply_ema(combined) self.last_active_frac = float((self.saliency > 0.25).mean()) return self.saliency # ─── Dual residual update ──────────────────────────────── def update_dual(self, gray, prev_gray, H=None, A=None, outlier_pts=None): """ Считает residual от homography и affine одновременно, в каждой точке берёт min (если любая модель объясняет — пиксель статичен). Устойчивее на сценах с несколькими плоскостями. H — homography 3x3 или None A — affine 2x3 или None """ if not self.enabled or gray is None: return None h, w = gray.shape[:2] diff_h = self._compute_diff_map(gray, prev_gray, H, "homography") if H is not None else None diff_a = self._compute_diff_map(gray, prev_gray, A, "affine") if A is not None else None if diff_h is not None and diff_a is not None: diff_map = np.minimum(diff_h, diff_a) self.last_kind = "dual" elif diff_h is not None: diff_map = diff_h self.last_kind = "homography" elif diff_a is not None: diff_map = diff_a self.last_kind = "affine" else: diff_map = None self.last_kind = "none" outlier_map = self._compute_outlier_density(outlier_pts, h, w) combined = self._combine(diff_map, outlier_map, h, w) self._apply_ema(combined) self.last_active_frac = float((self.saliency > 0.25).mean()) return self.saliency # ─── Scoring API ───────────────────────────────────────── def score_box(self, box): if self.saliency is None or box is None: return 0.0 h, w = self.saliency.shape[:2] x1 = int(clamp(box[0], 0, w - 1)) y1 = int(clamp(box[1], 0, h - 1)) x2 = int(clamp(box[2], x1 + 1, w)) y2 = int(clamp(box[3], y1 + 1, h)) patch = self.saliency[y1:y2, x1:x2] if patch.size == 0: return 0.0 return float(patch.mean()) def peak_box(self, box): if self.saliency is None or box is None: return 0.0 h, w = self.saliency.shape[:2] x1 = int(clamp(box[0], 0, w - 1)) y1 = int(clamp(box[1], 0, h - 1)) x2 = int(clamp(box[2], x1 + 1, w)) y2 = int(clamp(box[3], y1 + 1, h)) patch = self.saliency[y1:y2, x1:x2] if patch.size == 0: return 0.0 return float(patch.max()) def get_rois(self, thr=0.35, min_area=30, max_rois=6, pad_px=12): if self.saliency is None: return [] mask = (self.saliency >= float(thr)).astype(np.uint8) if mask.sum() < min_area: return [] n_labels, labels, stats, _ = cv2.connectedComponentsWithStats(mask, connectivity=8) rois = [] h, w = mask.shape[:2] for lbl in range(1, int(n_labels)): area = int(stats[lbl, cv2.CC_STAT_AREA]) if area < min_area: continue x = int(stats[lbl, cv2.CC_STAT_LEFT]) y = int(stats[lbl, cv2.CC_STAT_TOP]) ww = int(stats[lbl, cv2.CC_STAT_WIDTH]) hh = int(stats[lbl, cv2.CC_STAT_HEIGHT]) box = np.array( [x - pad_px, y - pad_px, x + ww + pad_px, y + hh + pad_px], dtype=np.float32, ) box = clip_box(box, w, h) rois.append((area, box)) rois.sort(key=lambda x: x[0], reverse=True) return [b for _, b in rois[:max_rois]] # ─── Internals ─────────────────────────────────────────── def _compute_diff_map(self, gray, prev_gray, M, kind): """Ego-compensated frame differencing → float32 [0..1].""" if prev_gray is None or gray.shape != prev_gray.shape: return None k = int(MS_BLUR_KSIZE) if k >= 3 and (k % 2) == 1: g_cur = cv2.GaussianBlur(gray, (k, k), 0) g_prv = cv2.GaussianBlur(prev_gray, (k, k), 0) else: g_cur = gray g_prv = prev_gray h, w = g_cur.shape[:2] warped = _warp_prev(g_prv, M, kind, (w, h)) diff = cv2.absdiff(g_cur, warped) _, bin_mask = cv2.threshold(diff, int(MS_DIFF_THR), 255, cv2.THRESH_BINARY) ko = int(MS_MORPH_OPEN_KSIZE) if ko >= 2: kern = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (ko, ko)) bin_mask = cv2.morphologyEx(bin_mask, cv2.MORPH_OPEN, kern) kc = int(MS_MORPH_CLOSE_KSIZE) if kc >= 2: kern = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kc, kc)) bin_mask = cv2.morphologyEx(bin_mask, cv2.MORPH_CLOSE, kern) amp = diff.astype(np.float32) / 255.0 gate = (bin_mask > 0).astype(np.float32) soft = cv2.GaussianBlur(amp * gate, (9, 9), 0) mx = float(soft.max()) if mx > 1e-6: soft = soft / mx self.raw_diff = soft return soft def _compute_outlier_density(self, outlier_pts, h, w): """KDE density из outlier точек.""" if outlier_pts is None or len(outlier_pts) == 0: self.outlier_density = None return None scale = 0.25 sh = max(1, int(h * scale)) sw = max(1, int(w * scale)) acc = np.zeros((sh, sw), dtype=np.float32) pts = np.asarray(outlier_pts, dtype=np.float32).reshape(-1, 2) for x, y in pts: xi = int(clamp(x * scale, 0, sw - 1)) yi = int(clamp(y * scale, 0, sh - 1)) acc[yi, xi] += 1.0 sigma = float(MS_OUTLIER_SIGMA_PX) * scale k = int(max(3, round(sigma * 6)) | 1) blur = cv2.GaussianBlur(acc, (k, k), sigma) density = cv2.resize(blur, (w, h), interpolation=cv2.INTER_LINEAR) mx = float(density.max()) if mx > 1e-6: density = density / mx self.outlier_density = density return density def _combine(self, diff_map, outlier_map, h, w): combined = np.zeros((h, w), dtype=np.float32) if diff_map is not None: combined += float(MS_DIFF_WEIGHT) * diff_map if outlier_map is not None: combined += float(MS_OUTLIER_WEIGHT) * outlier_map combined = np.clip(combined, 0.0, 1.0) if MS_MASK_TOP_FRAC > 0.0: combined[: int(h * MS_MASK_TOP_FRAC), :] = 0.0 if MS_MASK_BOTTOM_FRAC > 0.0: combined[int(h * (1.0 - MS_MASK_BOTTOM_FRAC)) :, :] = 0.0 return combined def _apply_ema(self, combined): if self.saliency is None or self.saliency.shape != combined.shape: self.saliency = combined else: a = self._ema_alpha self.saliency = a * self.saliency + (1.0 - a) * combined # ─── Overlay ───────────────────────────────────────────── def draw_overlay(self, frame_bgr, sx=1.0, sy=1.0): if not MS_DRAW_OVERLAY or self.saliency is None: return h, w = frame_bgr.shape[:2] sal = cv2.resize(self.saliency, (w, h), interpolation=cv2.INTER_LINEAR) sal_u8 = np.clip(sal * 255.0, 0, 255).astype(np.uint8) heat = cv2.applyColorMap(sal_u8, cv2.COLORMAP_JET) cv2.addWeighted(heat, float(MS_DRAW_ALPHA), frame_bgr, 1.0 - float(MS_DRAW_ALPHA), 0, frame_bgr) cv2.putText( frame_bgr, f"MS[{self.last_kind}] active={self.last_active_frac*100:.1f}%", (20, 230), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (255, 255, 255), 2, ) def status_line(self): if not self.enabled: return "Motion saliency disabled" return ( f"Motion saliency ready: diff_w={MS_DIFF_WEIGHT} " f"outlier_w={MS_OUTLIER_WEIGHT} ema={MS_EMA_ALPHA}" )