import cv2 import numpy as np from config import * from helpers import clamp # Camera motion compensation # ========================= # Поддерживает три режима оценки ego-motion: # - affine (2x3, 4 DoF через estimateAffinePartial2D) # - homography (3x3, 8 DoF через findHomography) # - adaptive: пытаемся homography, при плохом качестве падаем на affine # # Основные функции: # estimate_global_affine_ex — старая добрая affine + outliers # estimate_global_homography_ex — homography + outliers + meta качества # estimate_ego_motion_adaptive — автоматический выбор # ========================= # Пороги качества homography HOMO_MIN_INLIERS = 50 HOMO_MIN_FILLED_CELLS = 4 # из 3x3 = 9 ячеек HOMO_GRID_SIZE = 3 HOMO_MAX_CORNER_SHIFT = 120.0 # px — защита от вырожденных H def _rect_mask_exclude(gray, rects_norm): h, w = gray.shape[:2] mask = np.ones((h, w), dtype=np.uint8) * 255 for (x1n, y1n, x2n, y2n) in rects_norm: x1 = int(clamp(x1n * w, 0, w - 1)) y1 = int(clamp(y1n * h, 0, h - 1)) x2 = int(clamp(x2n * w, 0, w)) y2 = int(clamp(y2n * h, 0, h)) cv2.rectangle(mask, (x1, y1), (x2, y2), 0, -1) return mask def _lk_correspondences(prev_gray, gray): """Общий блок: feature detection + LK tracking. (p0, p1) или (None, None).""" if prev_gray is None or gray is None: return None, None mask = _rect_mask_exclude(prev_gray, CAM_MOTION_EXCLUDE_NORM) if CAM_MOTION_EXCLUDE_NORM else None pts = cv2.goodFeaturesToTrack( prev_gray, maxCorners=int(CAM_MOTION_MAX_CORNERS), qualityLevel=float(CAM_MOTION_QL), minDistance=int(CAM_MOTION_MIN_DIST), mask=mask, ) if pts is None or len(pts) < 25: return None, None nxt, st, err = cv2.calcOpticalFlowPyrLK( prev_gray, gray, pts, None, winSize=(21, 21), maxLevel=3 ) if nxt is None or st is None: return None, None st = st.reshape(-1).astype(bool) p0 = pts[st].reshape(-1, 2).astype(np.float32) p1 = nxt[st].reshape(-1, 2).astype(np.float32) if len(p0) < 25: return None, None return p0, p1 def _classify_outliers(p0, p1, inlier_mask): """ Разбивает точки текущего кадра (p1) на inliers/outliers. Отфильтровывает outliers с слишком малым/большим потоком (LK шум). """ inlier_mask = inlier_mask.reshape(-1).astype(bool) inliers_curr = p1[inlier_mask] outliers_curr = p1[~inlier_mask] if len(outliers_curr) == 0: return inliers_curr, outliers_curr flow = p1[~inlier_mask] - p0[~inlier_mask] mag = np.linalg.norm(flow, axis=1) if len(inliers_curr) > 0: ego_flow = p1[inlier_mask] - p0[inlier_mask] ego_med = float(np.linalg.norm(np.median(ego_flow, axis=0))) else: ego_med = 0.0 valid = (mag > max(1.5, ego_med * 1.8)) & (mag < 80.0) outliers_curr = outliers_curr[valid] return inliers_curr, outliers_curr # ───────────────────────────────────────────────────────────── # AFFINE # ───────────────────────────────────────────────────────────── def estimate_global_affine_ex(prev_gray, gray): empty = (None, np.zeros((0, 2), dtype=np.float32), np.zeros((0, 2), dtype=np.float32)) p0, p1 = _lk_correspondences(prev_gray, gray) if p0 is None: return empty A, inlier_mask = cv2.estimateAffinePartial2D( p0, p1, method=cv2.RANSAC, ransacReprojThreshold=float(CAM_MOTION_RANSAC_THR), ) if A is None or inlier_mask is None: return (A, np.zeros((0, 2), dtype=np.float32), p1) inliers_curr, outliers_curr = _classify_outliers(p0, p1, inlier_mask) return A, inliers_curr, outliers_curr def estimate_global_affine(prev_gray, gray): """Backward-compatible wrapper.""" A, _, _ = estimate_global_affine_ex(prev_gray, gray) return A # ───────────────────────────────────────────────────────────── # HOMOGRAPHY # ───────────────────────────────────────────────────────────── def estimate_global_homography_ex(prev_gray, gray): """ Homography 3x3. Возвращает (H, inliers_curr, outliers_curr, meta). meta: n_inliers, filled_cells, spatial_ok, corner_shift_ok """ empty_meta = { "n_inliers": 0, "filled_cells": 0, "spatial_ok": False, "corner_shift_ok": False, } empty = ( None, np.zeros((0, 2), dtype=np.float32), np.zeros((0, 2), dtype=np.float32), empty_meta, ) p0, p1 = _lk_correspondences(prev_gray, gray) if p0 is None: return empty H, inlier_mask = cv2.findHomography( p0, p1, method=cv2.RANSAC, ransacReprojThreshold=float(CAM_MOTION_RANSAC_THR), maxIters=2000, confidence=0.995, ) if H is None or inlier_mask is None: return empty inlier_mask_flat = inlier_mask.reshape(-1).astype(bool) n_inliers = int(inlier_mask_flat.sum()) # Распределение inliers по 3x3 сетке h, w = gray.shape[:2] gs = int(HOMO_GRID_SIZE) cells = np.zeros((gs, gs), dtype=np.uint8) if n_inliers > 0: inl = p1[inlier_mask_flat] gx = np.clip((inl[:, 0] / w * gs).astype(int), 0, gs - 1) gy = np.clip((inl[:, 1] / h * gs).astype(int), 0, gs - 1) for x, y in zip(gx, gy): cells[y, x] = 1 filled_cells = int(cells.sum()) spatial_ok = filled_cells >= int(HOMO_MIN_FILLED_CELLS) # Проверка вырождения: сдвиг углов кадра corners = np.array( [[0, 0], [w - 1, 0], [w - 1, h - 1], [0, h - 1]], dtype=np.float32 ).reshape(-1, 1, 2) try: warped_corners = cv2.perspectiveTransform(corners, H).reshape(-1, 2) shifts = np.linalg.norm(warped_corners - corners.reshape(-1, 2), axis=1) corner_shift_ok = bool(np.max(shifts) < float(HOMO_MAX_CORNER_SHIFT)) except cv2.error: corner_shift_ok = False meta = { "n_inliers": n_inliers, "filled_cells": filled_cells, "spatial_ok": spatial_ok, "corner_shift_ok": corner_shift_ok, } inliers_curr, outliers_curr = _classify_outliers(p0, p1, inlier_mask_flat) return H, inliers_curr, outliers_curr, meta def homography_is_plausible(H, meta): """Проходит ли homography пороги качества.""" if H is None: return False if meta.get("n_inliers", 0) < int(HOMO_MIN_INLIERS): return False if not meta.get("spatial_ok", False): return False if not meta.get("corner_shift_ok", False): return False try: det = float(np.linalg.det(H[:2, :2])) except Exception: return False if not (0.3 < abs(det) < 3.0): return False return True # ───────────────────────────────────────────────────────────── # ADAPTIVE SELECTION # ───────────────────────────────────────────────────────────── def estimate_ego_motion_adaptive(prev_gray, gray): """ Сначала homography, при плохом качестве — affine. Returns: M — 3x3 (homography) / 2x3 (affine) / None kind — "homography" | "affine" | "none" inliers — Nx2 точки статичного мира outliers — Nx2 точки независимо движущихся объектов """ H, h_in, h_out, meta = estimate_global_homography_ex(prev_gray, gray) if homography_is_plausible(H, meta): return H, "homography", h_in, h_out A, a_in, a_out = estimate_global_affine_ex(prev_gray, gray) if A is not None and affine_is_plausible(A): return A, "affine", a_in, a_out empty = np.zeros((0, 2), dtype=np.float32) return None, "none", empty, empty # ───────────────────────────────────────────────────────────── # Utilities / backward compat # ───────────────────────────────────────────────────────────── def apply_affine_to_point(A, x, y): return ( A[0, 0] * x + A[0, 1] * y + A[0, 2], A[1, 0] * x + A[1, 1] * y + A[1, 2], ) def affine_is_plausible(A): if A is None: return False tx = float(A[0, 2]) ty = float(A[1, 2]) shift = float(np.hypot(tx, ty)) if shift > float(CAM_MOTION_MAX_SHIFT_PX): return False a00 = float(A[0, 0]) a10 = float(A[1, 0]) col0_norm = float(np.hypot(a00, a10)) if col0_norm < 1e-6: return False if not (float(CAM_MOTION_SCALE_MIN) <= col0_norm <= float(CAM_MOTION_SCALE_MAX)): return False ang = float(np.degrees(np.arctan2(a10, a00))) if abs(ang) > float(CAM_MOTION_MAX_ROT_DEG): return False return True def warp_is_plausible(M, kind): if kind == "homography": return M is not None if kind == "affine": return affine_is_plausible(M) return False