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.

645 lines
19 KiB
Python

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)))