import cv2 import numpy as np from config import * # Helpers and utility functions # ========================= # HELPERS # ========================= def is_box_outside(box, w, h, margin=20): x1, y1, x2, y2 = [float(v) for v in box] return (x2 < -margin) or (y2 < -margin) or (x1 > (w + margin)) or (y1 > (h + margin)) def pick_best_det(dets_eff, ew, eh): if not dets_eff: return None best = None best_s = -1.0 for d in dets_eff: b = clip_box(d[:4], ew, eh) s = float(d[4]) a = box_area(b) score = s + 0.00015 * a if score > best_s: best_s = score best = b return best def pick_best_det_with_score(dets_eff, ew, eh): if not dets_eff: return None, 0.0 best = None best_s = -1.0 best_conf = 0.0 for d in dets_eff: b = clip_box(d[:4], ew, eh) conf = float(d[4]) a = box_area(b) score = conf + 0.00015 * a if score > best_s: best_s = score best = b best_conf = conf return best, best_conf def clamp(x, a, b): return max(a, min(b, x)) def box_center(box): x1, y1, x2, y2 = box return np.array([(x1 + x2) * 0.5, (y1 + y2) * 0.5], dtype=np.float32) def box_wh(box): x1, y1, x2, y2 = box return np.array([max(1.0, x2 - x1), max(1.0, y2 - y1)], dtype=np.float32) def box_area(box): w, h = box_wh(box) return float(w * h) def iou(a, b): ax1, ay1, ax2, ay2 = a bx1, by1, bx2, by2 = b ix1, iy1 = max(ax1, bx1), max(ay1, by1) ix2, iy2 = min(ax2, bx2), min(ay2, by2) inter = max(0.0, ix2 - ix1) * max(0.0, iy2 - iy1) if inter <= 0: return 0.0 union = box_area(a) + box_area(b) - inter return 0.0 if union <= 0 else float(inter / union) def clip_box(box, w, h): x1, y1, x2, y2 = box x1 = clamp(float(x1), 0.0, float(w - 2)) y1 = clamp(float(y1), 0.0, float(h - 2)) x2 = clamp(float(x2), 1.0, float(w - 1)) y2 = clamp(float(y2), 1.0, float(h - 1)) if x2 <= x1 + 1: x2 = x1 + 2 if y2 <= y1 + 1: y2 = y1 + 2 return np.array([x1, y1, x2, y2], dtype=np.float32) def crop_roi(frame, roi_box): x1, y1, x2, y2 = map(int, roi_box) return frame[y1:y2, x1:x2], x1, y1 def compute_hsv_hist(frame, box): x1, y1, x2, y2 = map(int, box) patch = frame[y1:y2, x1:x2] if patch.size == 0: return None hsv = cv2.cvtColor(patch, cv2.COLOR_BGR2HSV) hist = cv2.calcHist([hsv], [0, 1], None, list(HSV_HIST_BINS), [0, 180, 0, 256]) cv2.normalize(hist, hist, alpha=0, beta=1, norm_type=cv2.NORM_MINMAX) return hist def hsv_sim(h1, h2): if h1 is None or h2 is None: return 0.0 d = cv2.compareHist(h1, h2, cv2.HISTCMP_BHATTACHARYYA) return float(1.0 - d) def safe_ratio(a, b): a = float(max(1e-3, a)) b = float(max(1e-3, b)) return max(a / b, b / a) def box_ar(box): w, h = box_wh(box) return float(w / max(1e-3, h)) def blend_hist(ref_hist, new_hist, alpha=0.85): if ref_hist is None: return new_hist if new_hist is None: return ref_hist out = (alpha * ref_hist) + ((1.0 - alpha) * new_hist) out = out.astype(np.float32, copy=False) cv2.normalize(out, out, alpha=0, beta=1, norm_type=cv2.NORM_MINMAX) return out def preprocess_for_yolo(frame_bgr): if (not ANALOG_FPV_MODE) or (not APPLY_YOLO_PREPROC): return frame_bgr out = frame_bgr if PRE_BLUR_K >= 3: k = int(PRE_BLUR_K) if (k % 2) == 0: k += 1 out = cv2.GaussianBlur(out, (k, k), 0) ycrcb = cv2.cvtColor(out, cv2.COLOR_BGR2YCrCb) y, cr, cb = cv2.split(ycrcb) clahe = cv2.createCLAHE(clipLimit=float(PRE_CLAHE_CLIP), tileGridSize=PRE_CLAHE_TILE) y = clahe.apply(y) out = cv2.cvtColor(cv2.merge((y, cr, cb)), cv2.COLOR_YCrCb2BGR) if PRE_UNSHARP > 1e-6: blur = cv2.GaussianBlur(out, (0, 0), 1.1) out = cv2.addWeighted(out, 1.0 + float(PRE_UNSHARP), blur, -float(PRE_UNSHARP), 0) return out def in_norm_rect(x, y, w, h, rect_norm): rx1, ry1, rx2, ry2 = rect_norm return (rx1 * w) <= x <= (rx2 * w) and (ry1 * h) <= y <= (ry2 * h) def in_osd_zone(center_x, center_y, frame_w, frame_h): for z in REJECT_OSD_ZONES_NORM: if in_norm_rect(center_x, center_y, frame_w, frame_h, z): return True return False def filter_yolo_boxes_with_scores(result, frame_w, frame_h, offset_x=0, offset_y=0, min_conf=0.12): dets_out = [] if result.boxes is None or len(result.boxes) == 0: return dets_out xyxy = result.boxes.xyxy.detach().cpu().numpy() confs = result.boxes.conf.detach().cpu().numpy() clss = result.boxes.cls.detach().cpu().numpy().astype(int) for b, c, cls_id in zip(xyxy, confs, clss): score = float(c) if score < float(min_conf): continue if TARGET_CLASS_ID is not None and cls_id != TARGET_CLASS_ID: continue x1, y1, x2, y2 = map(float, b) ww = max(0.0, x2 - x1) hh = max(0.0, y2 - y1) if ww <= 1.0 or hh <= 1.0: continue a = ww * hh if a < MIN_BOX_AREA: continue if MAX_BOX_AREA > 0 and a > MAX_BOX_AREA: continue ar = ww / hh if not (ASPECT_RANGE[0] <= ar <= ASPECT_RANGE[1]): continue gx1, gy1, gx2, gy2 = x1 + offset_x, y1 + offset_y, x2 + offset_x, y2 + offset_y if REJECT_OSD_ZONES: cx = 0.5 * (gx1 + gx2) cy = 0.5 * (gy1 + gy2) if (a <= REJECT_OSD_SMALL_AREA_MAX) and in_osd_zone(cx, cy, frame_w, frame_h): continue dets_out.append(np.array([gx1, gy1, gx2, gy2, score], dtype=np.float32)) return dets_out def merge_close_part_dets(dets_eff, ref_box, frame_w, frame_h): if (not PART_MERGE_ENABLE) or ref_box is None or len(dets_eff) < int(max(2, PART_MERGE_MIN_PARTS)): return dets_eff, 0 ref = clip_box(ref_box, frame_w, frame_h) ref_center = box_center(ref) ref_diag = max(float(np.linalg.norm(box_wh(ref))), float(PART_MERGE_MIN_REF_DIAG)) ref_area = box_area(ref) boxes = [clip_box(d[:4], frame_w, frame_h) for d in dets_eff] scores = [float(d[4]) for d in dets_eff] near_ids = [] for idx, b in enumerate(boxes): c = box_center(b) dist = float(np.linalg.norm(c - ref_center)) if (dist <= float(PART_MERGE_REF_DIST_DIAG) * ref_diag) or (iou(b, ref) >= float(PART_MERGE_IOU_FLOOR)): near_ids.append(idx) if len(near_ids) < int(max(2, PART_MERGE_MIN_PARTS)): return dets_eff, 0 seed = max(near_ids, key=lambda ii: scores[ii]) cluster = {int(seed)} changed = True while changed: changed = False cluster_boxes = [boxes[ii] for ii in cluster] ux1 = min(float(b[0]) for b in cluster_boxes) uy1 = min(float(b[1]) for b in cluster_boxes) ux2 = max(float(b[2]) for b in cluster_boxes) uy2 = max(float(b[3]) for b in cluster_boxes) union_box = clip_box([ux1, uy1, ux2, uy2], frame_w, frame_h) union_center = box_center(union_box) union_diag = max(float(np.linalg.norm(box_wh(union_box))), ref_diag) for idx in near_ids: if idx in cluster: continue b = boxes[idx] c = box_center(b) center_close = float(np.linalg.norm(c - union_center)) <= (float(PART_MERGE_CLUSTER_DIST_DIAG) * union_diag) overlap = iou(b, union_box) >= float(PART_MERGE_IOU_FLOOR) if center_close or overlap: cluster.add(int(idx)) changed = True if len(cluster) < int(max(2, PART_MERGE_MIN_PARTS)): return dets_eff, 0 cluster_boxes = [boxes[ii] for ii in cluster] ux1 = min(float(b[0]) for b in cluster_boxes) uy1 = min(float(b[1]) for b in cluster_boxes) ux2 = max(float(b[2]) for b in cluster_boxes) uy2 = max(float(b[3]) for b in cluster_boxes) union_box = clip_box([ux1, uy1, ux2, uy2], frame_w, frame_h) union_area = box_area(union_box) max_part_area = max(box_area(b) for b in cluster_boxes) union_ar = box_ar(union_box) if union_area < (float(PART_MERGE_MIN_UNION_GAIN) * max_part_area): return dets_eff, 0 if union_area > (float(PART_MERGE_MAX_UNION_RATIO) * max(1.0, ref_area)): return dets_eff, 0 if union_ar > float(PART_MERGE_MAX_ASPECT): return dets_eff, 0 merged_score = min(0.99, max(scores[ii] for ii in cluster) + float(PART_MERGE_SCORE_BONUS)) merged_det = np.array([union_box[0], union_box[1], union_box[2], union_box[3], merged_score], dtype=np.float32) out = [dets_eff[idx] for idx in range(len(dets_eff)) if idx not in cluster] out.append(merged_det) return out, int(len(cluster)) def _odd_ksize(k): k = int(max(1, k)) if (k % 2) == 0: k += 1 return k def build_motion_mask(prev_gray, gray_now, affine=None): if prev_gray is None or gray_now is None: return None h, w = gray_now.shape[:2] ref = prev_gray if affine is not None: ref = cv2.warpAffine( prev_gray, affine, (w, h), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE ) cur = gray_now if MOTION_BLUR_K >= 3: bk = _odd_ksize(MOTION_BLUR_K) ref = cv2.GaussianBlur(ref, (bk, bk), 0) cur = cv2.GaussianBlur(cur, (bk, bk), 0) diff = cv2.absdiff(cur, ref) _, mask = cv2.threshold(diff, int(MOTION_DIFF_THR), 255, cv2.THRESH_BINARY) mk = int(max(1, MOTION_MORPH_K)) if mk > 1: kernel = np.ones((mk, mk), dtype=np.uint8) mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) mask = cv2.dilate(mask, kernel, iterations=1) return mask def motion_zones_to_roi(mask, frame_w, frame_h): if mask is None or mask.size == 0: return None, 0 gx = int(max(1, MOTION_ZONE_GRID[0])) gy = int(max(1, MOTION_ZONE_GRID[1])) zone_w = float(frame_w) / float(gx) zone_h = float(frame_h) / float(gy) active = [] for iy in range(gy): y1 = int(round(iy * zone_h)) y2 = int(round((iy + 1) * zone_h)) y2 = max(y2, y1 + 1) for ix in range(gx): x1 = int(round(ix * zone_w)) x2 = int(round((ix + 1) * zone_w)) x2 = max(x2, x1 + 1) patch = mask[y1:y2, x1:x2] if patch.size == 0: continue px = int(cv2.countNonZero(patch)) area = max(1, (x2 - x1) * (y2 - y1)) min_need = max(int(MOTION_ZONE_MIN_PIXELS), int(area * float(MOTION_ZONE_MIN_RATIO))) if px >= min_need: active.append((x1, y1, x2, y2)) active_count = len(active) if active_count == 0: return None, 0 max_active = int(round(float(gx * gy) * float(MOTION_MAX_ACTIVE_ZONE_RATIO))) max_active = max(1, max_active) if active_count > max_active: return None, active_count ux1 = min(z[0] for z in active) - int(MOTION_ROI_MARGIN) uy1 = min(z[1] for z in active) - int(MOTION_ROI_MARGIN) ux2 = max(z[2] for z in active) + int(MOTION_ROI_MARGIN) uy2 = max(z[3] for z in active) + int(MOTION_ROI_MARGIN) roi = clip_box([ux1, uy1, ux2, uy2], frame_w, frame_h) rw = float(roi[2] - roi[0]) rh = float(roi[3] - roi[1]) min_side = float(max(2, MOTION_ROI_MIN_SIZE)) if rw < min_side or rh < min_side: cx, cy = box_center(roi) rw = max(rw, min_side) rh = max(rh, min_side) roi = clip_box([cx - rw * 0.5, cy - rh * 0.5, cx + rw * 0.5, cy + rh * 0.5], frame_w, frame_h) return roi, active_count def box_motion_stats(mask, box): if mask is None or mask.size == 0: return 0, 0.0 h, w = mask.shape[:2] x1, y1, x2, y2 = map(int, clip_box(box, w, h)) patch = mask[y1:y2, x1:x2] if patch.size == 0: return 0, 0.0 px = int(cv2.countNonZero(patch)) ratio = float(px) / float(max(1, patch.shape[0] * patch.shape[1])) return px, ratio def wavelet_energy_haar(gray, box, margin=8, min_side=20): if gray is None: return 0.0 h, w = gray.shape[:2] b = clip_box(box, w, h) cx, cy = box_center(b) bw, bh = box_wh(b) side = max(float(min_side), max(float(bw), float(bh)) + 2.0 * float(margin)) x1 = int(max(0, round(cx - 0.5 * side))) y1 = int(max(0, round(cy - 0.5 * side))) x2 = int(min(w, round(cx + 0.5 * side))) y2 = int(min(h, round(cy + 0.5 * side))) if x2 - x1 < 4 or y2 - y1 < 4: return 0.0 p = gray[y1:y2, x1:x2] if p.size == 0: return 0.0 # 1-level Haar on 2x2 quads, then normalized high-frequency energy. if (p.shape[0] % 2) == 1: p = p[:-1, :] if (p.shape[1] % 2) == 1: p = p[:, :-1] if p.shape[0] < 4 or p.shape[1] < 4: return 0.0 f = p.astype(np.float32) / 255.0 a = f[0::2, 0::2] b0 = f[0::2, 1::2] c0 = f[1::2, 0::2] d0 = f[1::2, 1::2] lh = a - b0 + c0 - d0 hl = a + b0 - c0 - d0 hh = a - b0 - c0 + d0 ll = 0.25 * (a + b0 + c0 + d0) hf = (np.mean(np.abs(lh)) + np.mean(np.abs(hl)) + np.mean(np.abs(hh))) / 3.0 lf = float(np.mean(np.abs(ll)) + 1e-3) return float(hf / lf) def wavelet_hot_roi( gray, frame_w, frame_h, motion_mask=None, pred_ref=None, margin=20, min_side=84, pred_scale=3.0, min_peak_ratio=1.8, ): if gray is None: return None, 0.0 g = gray if (g.shape[0] % 2) == 1: g = g[:-1, :] if (g.shape[1] % 2) == 1: g = g[:, :-1] if g.shape[0] < 8 or g.shape[1] < 8: return None, 0.0 f = g.astype(np.float32) / 255.0 a = f[0::2, 0::2] b0 = f[0::2, 1::2] c0 = f[1::2, 0::2] d0 = f[1::2, 1::2] lh = a - b0 + c0 - d0 hl = a + b0 - c0 - d0 hh = a - b0 - c0 + d0 e = (np.abs(lh) + np.abs(hl) + np.abs(hh)) / 3.0 e = cv2.GaussianBlur(e, (0, 0), 1.0) if motion_mask is not None and motion_mask.size > 0: m = cv2.resize( (motion_mask.astype(np.float32) / 255.0), (e.shape[1], e.shape[0]), interpolation=cv2.INTER_AREA, ) e *= (0.25 + 0.75 * m) if pred_ref is not None: pc = box_center(pred_ref) * 0.5 # energy map is downsampled by 2 ys, xs = np.indices(e.shape, dtype=np.float32) sig = max(8.0, 0.22 * float(max(e.shape[0], e.shape[1]))) prior = np.exp(-((xs - pc[0]) ** 2 + (ys - pc[1]) ** 2) / (2.0 * sig * sig)) e *= (0.70 + 0.30 * prior) peak = float(np.max(e)) mean = float(np.mean(e) + 1e-6) peak_ratio = peak / mean if peak_ratio < float(min_peak_ratio): return None, peak_ratio iy, ix = np.unravel_index(np.argmax(e), e.shape) cx = (float(ix) + 0.5) * 2.0 cy = (float(iy) + 0.5) * 2.0 base_side = max(float(min_side), float(2.0 * margin + 24.0)) if pred_ref is not None: pdiag = float(np.linalg.norm(box_wh(pred_ref))) side = max(base_side, float(pred_scale) * pdiag) else: side = base_side roi = clip_box([cx - 0.5 * side, cy - 0.5 * side, cx + 0.5 * side, cy + 0.5 * side], frame_w, frame_h) return roi, peak_ratio def recover_det_is_valid(det, motion_mask): score = float(det[4]) if score < float(RECOVER_MIN_SCORE): return False b = det[:4] a = box_area(b) if a <= float(RECOVER_TINY_AREA_MAX): if score < float(RECOVER_TINY_MIN_SCORE): return False px, ratio = box_motion_stats(motion_mask, b) if px < int(RECOVER_TINY_MIN_MOTION_PIXELS) and ratio < float(RECOVER_TINY_MIN_MOTION_RATIO): return False return True def make_focus_roi_from_box(box, frame_w, frame_h, margin=72, min_side=90): b = clip_box(box, frame_w, frame_h) cx, cy = box_center(b) bw, bh = box_wh(b) rw = max(float(min_side), float(bw) + 2.0 * float(margin)) rh = max(float(min_side), float(bh) + 2.0 * float(margin)) return clip_box([cx - 0.5 * rw, cy - 0.5 * rh, cx + 0.5 * rw, cy + 0.5 * rh], frame_w, frame_h) def pick_preacq_det(dets_eff, pred_ref, frame_w, frame_h): if not dets_eff or pred_ref is None: return None pc = box_center(pred_ref) pdiag = float(np.linalg.norm(box_wh(pred_ref))) near_lim = max(float(PREACQ_NEAR_MIN), float(PREACQ_NEAR_FACTOR) * pdiag) best = None best_s = -1e9 for d in dets_eff: score = float(d[4]) if score < float(PREACQ_MIN_SCORE): continue b = clip_box(d[:4], frame_w, frame_h) c = box_center(b) dist = float(np.linalg.norm(c - pc)) if dist > near_lim: continue s = score - (0.0025 * dist) if s > best_s: best_s = s best = np.array([b[0], b[1], b[2], b[3], score], dtype=np.float32) return best def track_residual_motion_ok(track, motion_mask, frame_w, frame_h): if motion_mask is None: return False b = clip_box(track.tlbr, frame_w, frame_h) px, ratio = box_motion_stats(motion_mask, b) return (px >= int(EGO_RESIDUAL_MIN_PIXELS)) or (ratio >= float(EGO_RESIDUAL_MIN_RATIO)) def get_effective_frame(orig_bgr): oh, ow = orig_bgr.shape[:2] if not FORCE_EFFECTIVE_PAL: return orig_bgr, 1.0, 1.0 eff = cv2.resize(orig_bgr, (EFFECTIVE_W, EFFECTIVE_H), interpolation=cv2.INTER_AREA) sx = EFFECTIVE_W / float(ow) sy = EFFECTIVE_H / float(oh) return eff, sx, sy def unscale_box(box_eff, sx, sy): x1, y1, x2, y2 = box_eff return np.array([x1 / sx, y1 / sy, x2 / sx, y2 / sy], dtype=np.float32) def is_int_source(src): if isinstance(src, int): return True if isinstance(src, str) and src.isdigit(): return True return False def is_stream_source(src): if not isinstance(src, str): return False s = src.lower() return ( s.startswith("rtsp://") or s.startswith("udp://") or s.startswith("rtmp://") or s.startswith("http://") or s.startswith("https://") ) def open_source(source, backend=cv2.CAP_DSHOW): if is_int_source(source): cap = cv2.VideoCapture(int(source), backend) return cap, "camera" if isinstance(source, str): if is_stream_source(source): cap = cv2.VideoCapture(source) return cap, "stream" cap = cv2.VideoCapture(source) return cap, "file" raise ValueError(f"Unsupported SOURCE type: {type(source)}") def get_frame_timestamp_seconds(cap, source_kind, frame_id, input_fps, loop_ts): if TIMESTAMP_USE_SOURCE_CLOCK: pos_ms = float(cap.get(cv2.CAP_PROP_POS_MSEC)) if np.isfinite(pos_ms) and pos_ms > 0.0: return float(pos_ms * 1e-3), "source" if source_kind == "file" and input_fps > 1.0: return float(frame_id) / float(input_fps), "fps" return float(loop_ts), "wall" def sanitize_dt(frame_ts, prev_frame_ts, nominal_dt): dt = float(nominal_dt) if prev_frame_ts is not None: raw_dt = float(frame_ts - prev_frame_ts) if np.isfinite(raw_dt) and (1e-6 < raw_dt <= float(DT_RESET_GAP_SEC)): dt = raw_dt return float(clamp(dt, float(DT_MIN_SEC), float(DT_MAX_SEC)))