ipcam-mog2.py raw
1 #!/usr/bin/env python3
2 # MOG2 background subtraction motion detector
3 # Reads video, outputs motion segment timestamps (start end) one per line
4 # Usage: ipcam-mog2.py input.mkv [min_area_pct] [gap_sec] [history] [var_threshold]
5 import sys
6 import cv2
7 import numpy as np
8
9 if len(sys.argv) < 2:
10 print("usage: ipcam-mog2.py input.mkv [min_area%] [gap_sec] [history] [var_thresh]", file=sys.stderr)
11 sys.exit(1)
12
13 path = sys.argv[1]
14 min_area_pct = float(sys.argv[2]) if len(sys.argv) > 2 else 0.5
15 gap_sec = float(sys.argv[3]) if len(sys.argv) > 3 else 1.0
16 history = int(sys.argv[4]) if len(sys.argv) > 4 else 500
17 var_thresh = float(sys.argv[5]) if len(sys.argv) > 5 else 16.0
18
19 cap = cv2.VideoCapture(path)
20 if not cap.isOpened():
21 print(f"cannot open {path}", file=sys.stderr)
22 sys.exit(1)
23
24 fps = cap.get(cv2.CAP_PROP_FPS)
25 w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
26 h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
27 total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
28
29 # downscale for speed - process at 320px wide
30 scale = 320.0 / w if w > 320 else 1.0
31 sw, sh = int(w * scale), int(h * scale)
32 total_pixels = sw * sh
33 min_pixels = total_pixels * (min_area_pct / 100.0)
34
35 mog = cv2.createBackgroundSubtractorMOG2(
36 history=history,
37 varThreshold=var_thresh,
38 detectShadows=True,
39 )
40 # shadow detection marks shadow pixels as 127 vs foreground 255
41 # learning rate: -1 = automatic, or set explicitly (0.001-0.01 typical)
42 learn_rate = -1
43
44 kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
45
46 segments = []
47 in_motion = False
48 seg_start = 0.0
49 seg_end = 0.0
50 frame_idx = 0
51 motion_frames = 0
52 still_frames = 0
53
54 while True:
55 ret, frame = cap.read()
56 if not ret:
57 break
58 t = frame_idx / fps
59
60 small = cv2.resize(frame, (sw, sh)) if scale < 1.0 else frame
61 mask = mog.apply(small, learningRate=learn_rate)
62
63 # threshold: only foreground (255), ignore shadows (127)
64 _, mask = cv2.threshold(mask, 200, 255, cv2.THRESH_BINARY)
65
66 # morphological open to kill noise, close to fill gaps
67 mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
68 mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
69
70 fg_pixels = cv2.countNonZero(mask)
71 motion = fg_pixels > min_pixels
72
73 if motion:
74 still_frames = 0
75 motion_frames += 1
76 if not in_motion:
77 in_motion = True
78 seg_start = t
79 seg_end = t
80 else:
81 still_frames += 1
82 motion_frames = 0
83 if in_motion and (t - seg_end) > gap_sec:
84 segments.append((seg_start, seg_end))
85 in_motion = False
86
87 frame_idx += 1
88
89 cap.release()
90
91 if in_motion:
92 segments.append((seg_start, seg_end))
93
94 # merge segments that are closer than gap_sec
95 merged = []
96 for s, e in segments:
97 if merged and s - merged[-1][1] <= gap_sec:
98 merged[-1] = (merged[-1][0], e)
99 else:
100 merged.append((s, e))
101
102 if not merged:
103 print("no motion detected", file=sys.stderr)
104 sys.exit(1)
105
106 print(f"frames={frame_idx} fps={fps:.1f} area_thresh={min_area_pct}% "
107 f"({int(min_pixels)}px) gap={gap_sec}s history={history} "
108 f"var_thresh={var_thresh}", file=sys.stderr)
109 print(f"{len(merged)} segments", file=sys.stderr)
110
111 for s, e in merged:
112 sh_, sm, ss = int(s // 3600), int((s % 3600) // 60), s % 60
113 eh, em, es = int(e // 3600), int((e % 3600) // 60), e % 60
114 print(f"{sh_:02d}:{sm:02d}:{ss:06.3f} {eh:02d}:{em:02d}:{es:06.3f}")
115