import cv2 import numpy as np import time from config import * from ffmpeg_capture import FFmpegCapture # 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 box_is_edge_osd(box, frame_w, frame_h): if not (REJECT_OSD_ZONES and REJECT_OSD_EDGE_ANCHORED): return False b = clip_box(box, frame_w, frame_h) cx, cy = box_center(b) margin = float(max(0, REJECT_OSD_EDGE_MARGIN)) touches_edge = bool( b[0] <= margin or b[1] <= margin or b[2] >= float(frame_w) - margin or b[3] >= float(frame_h) - margin ) return touches_edge and in_osd_zone(float(cx), float(cy), frame_w, frame_h) def box_is_osd_candidate(box, frame_w, frame_h): if not REJECT_OSD_ZONES: return False b = clip_box(box, frame_w, frame_h) cx, cy = box_center(b) return bool( box_is_edge_osd(b, frame_w, frame_h) or ( box_area(b) <= float(REJECT_OSD_SMALL_AREA_MAX) and in_osd_zone(float(cx), float(cy), frame_w, frame_h) ) ) def filter_yolo_boxes_with_scores( result, frame_w, frame_h, offset_x=0, offset_y=0, min_conf=0.12, input_scale=1.0, pad_x=0.0, pad_y=0.0, content_w=None, content_h=None, ): 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) inv_scale = 1.0 / max(float(input_scale), 1e-9) x1 = (x1 - float(pad_x)) * inv_scale y1 = (y1 - float(pad_y)) * inv_scale x2 = (x2 - float(pad_x)) * inv_scale y2 = (y2 - float(pad_y)) * inv_scale if content_w is not None: x1 = float(np.clip(x1, 0.0, float(content_w))) x2 = float(np.clip(x2, 0.0, float(content_w))) if content_h is not None: y1 = float(np.clip(y1, 0.0, float(content_h))) y2 = float(np.clip(y2, 0.0, float(content_h))) 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: if box_is_osd_candidate( [gx1, gy1, gx2, gy2], 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 filter_drone_candidates(dets, motion_mask, frame_w, frame_h, reference_box=None): if not DRONE_CANDIDATE_GATE_ENABLE: return list(dets) accepted = [] frame_area = max(1.0, float(frame_w) * float(frame_h)) ref = None if reference_box is None else clip_box(reference_box, frame_w, frame_h) ref_center = None if ref is None else box_center(ref) ref_diag = 1.0 if ref is None else max(1.0, float(np.linalg.norm(box_wh(ref)))) for det in dets: score = float(det[4]) box = clip_box(det[:4], frame_w, frame_h) if score < float(DRONE_CANDIDATE_MIN_SCORE): continue if box_area(box) / frame_area > float(DRONE_CANDIDATE_MAX_FRAME_AREA_RATIO): continue near_locked_target = False if ref_center is not None: distance = float(np.linalg.norm(box_center(box) - ref_center)) near_locked_target = ( iou(box, ref) >= float(DRONE_CANDIDATE_NEAR_IOU) or distance <= max( float(DRONE_CANDIDATE_NEAR_MIN), float(DRONE_CANDIDATE_NEAR_FACTOR) * ref_diag, ) ) if score >= float(DRONE_CANDIDATE_STRONG_SCORE) or near_locked_target: accepted.append(det) continue motion_pixels, motion_ratio = box_motion_stats(motion_mask, box) if ( motion_pixels >= int(DRONE_CANDIDATE_MIN_MOTION_PIXELS) or motion_ratio >= float(DRONE_CANDIDATE_MIN_MOTION_RATIO) ): accepted.append(det) return accepted def verified_drone_track_box(track, motion_mask, frame_w, frame_h): if track is None or int(getattr(track, "time_since_update", 1)) != 0: return None if int(getattr(track, "hits", 0)) < int(DRONE_RED_MIN_TRACK_HITS): return None score = float(getattr(track, "score", 0.0)) box = clip_box(track.tlbr, frame_w, frame_h) if score >= float(DRONE_RED_MIN_SCORE): return box motion_pixels, motion_ratio = box_motion_stats(motion_mask, box) if ( motion_pixels >= int(DRONE_RED_MIN_MOTION_PIXELS) or motion_ratio >= float(DRONE_RED_MIN_MOTION_RATIO) ): return box return None 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 acquisition_step_is_plausible(previous_box, current_box, affine, dt, frame_w, frame_h): if previous_box is None or current_box is None: return True previous_center = box_center(previous_box) expected_center = previous_center if affine is not None: matrix = np.asarray(affine, dtype=np.float32) if matrix.shape == (2, 3) and np.all(np.isfinite(matrix)): expected_center = np.array( [ matrix[0, 0] * previous_center[0] + matrix[0, 1] * previous_center[1] + matrix[0, 2], matrix[1, 0] * previous_center[0] + matrix[1, 1] * previous_center[1] + matrix[1, 2], ], dtype=np.float32, ) residual = float(np.linalg.norm(box_center(current_box) - expected_center)) frame_area = max(1.0, float(frame_w * frame_h)) area_ratio = box_area(current_box) / frame_area near_factor = float( np.clip( np.sqrt(area_ratio / max(1e-6, float(PHYSICS_NEAR_AREA_RATIO))), 0.0, 1.0, ) ) max_speed = ( (1.0 - near_factor) * float(PHYSICS_FAR_MAX_SPEED_NORM_S) + near_factor * float(PHYSICS_NEAR_MAX_SPEED_NORM_S) ) previous_diag = float(np.linalg.norm(box_wh(previous_box))) allowed = max( 10.0, max_speed * min(frame_w, frame_h) * max(float(dt), float(DT_MIN_SEC)), 0.65 * previous_diag, ) previous_area = max(1.0, box_area(previous_box)) size_ratio = max( box_area(current_box) / previous_area, previous_area / max(1.0, box_area(current_box)), ) return residual <= allowed and size_ratio <= 3.0 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://") ) class ReconnectingVideoCapture: def __init__(self, source, retries=20, retry_delay=0.2): self.source = source self.retries = max(1, int(retries)) self.retry_delay = max(0.0, float(retry_delay)) self.closed = False self.cap = cv2.VideoCapture(source) def isOpened(self): return not self.closed and self.cap is not None and self.cap.isOpened() def read(self): if self.closed: return False, None ok, frame = self.cap.read() if ok and frame is not None: return True, frame for _ in range(self.retries): self.cap.release() if self.retry_delay: time.sleep(self.retry_delay) if self.closed: return False, None self.cap = cv2.VideoCapture(self.source) if not self.cap.isOpened(): continue ok, frame = self.cap.read() if ok and frame is not None: return True, frame return False, None def get(self, prop): return self.cap.get(prop) if self.cap is not None else 0.0 def set(self, prop, value): return bool(self.cap is not None and self.cap.set(prop, value)) def release(self): self.closed = True if self.cap is not None: self.cap.release() def open_source(source, backend=cv2.CAP_FFMPEG): if is_int_source(source): cap = cv2.VideoCapture(int(source), backend) return cap, "camera" if isinstance(source, str): if is_stream_source(source): cap = ReconnectingVideoCapture(source) return cap, "stream" cap = FFmpegCapture(source) if cap.isOpened(): return cap, "file" cap = cv2.VideoCapture(source, cv2.CAP_FFMPEG) 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)))