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.

331 lines
13 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.

# ============================================================
# 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}"
)