2525def create_background_subtractor () -> cv2 .BackgroundSubtractor :
2626 """
2727 Create and return a MOG2 background subtractor with sensible defaults.
28+
29+ Doctest:
30+ >>> subtractor = create_background_subtractor()
31+ >>> hasattr(subtractor, "apply")
32+ True
2833 """
2934 # history=500, varThreshold=16 are common defaults; detectShadows adds robustness
3035 return cv2 .createBackgroundSubtractorMOG2 (history = 500 , varThreshold = 16 , detectShadows = True )
@@ -33,6 +38,12 @@ def create_background_subtractor() -> cv2.BackgroundSubtractor:
3338def preprocess_frame (frame : cv2 .Mat ) -> cv2 .Mat :
3439 """
3540 Convert to grayscale and apply Gaussian blur to suppress noise.
41+
42+ Doctest:
43+ >>> dummy = np.zeros((10, 10, 3), dtype=np.uint8)
44+ >>> out = preprocess_frame(dummy)
45+ >>> out.shape == (10, 10) and out.dtype == np.uint8
46+ True
3647 """
3748 gray = cv2 .cvtColor (frame , cv2 .COLOR_BGR2GRAY )
3849 blurred = cv2 .GaussianBlur (gray , (5 , 5 ), 0 )
@@ -43,6 +54,16 @@ def frame_difference(prev_gray: cv2.Mat, curr_gray: cv2.Mat) -> cv2.Mat:
4354 """
4455 Compute absolute difference between consecutive grayscale frames.
4556 Returns a binary motion mask after thresholding and morphology.
57+
58+ Doctest:
59+ >>> a = np.zeros((8, 8), dtype=np.uint8)
60+ >>> b = np.zeros((8, 8), dtype=np.uint8)
61+ >>> b[2:6, 2:6] = 255
62+ >>> mask = frame_difference(a, b)
63+ >>> mask.shape
64+ (8, 8)
65+ >>> mask.dtype == np.uint8
66+ True
4667 """
4768 diff = cv2 .absdiff (prev_gray , curr_gray )
4869 _ , thresh = cv2 .threshold (diff , 25 , 255 , cv2 .THRESH_BINARY )
@@ -55,6 +76,15 @@ def frame_difference(prev_gray: cv2.Mat, curr_gray: cv2.Mat) -> cv2.Mat:
5576def background_subtraction_mask (subtractor : cv2 .BackgroundSubtractor , frame : cv2 .Mat ) -> cv2 .Mat :
5677 """
5778 Apply background subtraction to obtain a motion mask. Includes morphology.
79+
80+ Doctest:
81+ >>> subtractor = create_background_subtractor()
82+ >>> frame = np.zeros((12, 12, 3), dtype=np.uint8)
83+ >>> mask = background_subtraction_mask(subtractor, frame)
84+ >>> mask.shape
85+ (12, 12)
86+ >>> mask.dtype == np.uint8
87+ True
5888 """
5989 fg_mask = subtractor .apply (frame )
6090 # Remove shadows if present (MOG2 shadows are typically 127)
@@ -68,6 +98,14 @@ def background_subtraction_mask(subtractor: cv2.BackgroundSubtractor, frame: cv2
6898def annotate_motion (frame : cv2 .Mat , motion_mask : cv2 .Mat ) -> cv2 .Mat :
6999 """
70100 Find contours on the motion mask and draw bounding boxes on the frame.
101+
102+ Doctest:
103+ >>> frame = np.zeros((60, 60, 3), dtype=np.uint8)
104+ >>> mask = np.zeros((60, 60), dtype=np.uint8)
105+ >>> mask[10:40, 10:40] = 255 # large enough to exceed MIN_CONTOUR_AREA
106+ >>> annotated = annotate_motion(frame, mask)
107+ >>> np.any(annotated[..., 1] == 255) # green channel from rectangle
108+ True
71109 """
72110 contours , _ = cv2 .findContours (motion_mask , cv2 .RETR_EXTERNAL , cv2 .CHAIN_APPROX_SIMPLE )
73111 annotated = frame .copy ()
@@ -80,6 +118,19 @@ def annotate_motion(frame: cv2.Mat, motion_mask: cv2.Mat) -> cv2.Mat:
80118
81119
82120def main () -> None :
121+ """
122+ Run motion detection loop for the configured VIDEO_SOURCE.
123+
124+ Doctest (expect a RuntimeError when pointing to an invalid source):
125+ >>> _prev = VIDEO_SOURCE
126+ >>> VIDEO_SOURCE = "nonexistent_file_does_not_exist.mp4"
127+ >>> try:
128+ ... main()
129+ ... except RuntimeError as e:
130+ ... isinstance(e, RuntimeError)
131+ True
132+ >>> VIDEO_SOURCE = _prev
133+ """
83134 cap = cv2 .VideoCapture (VIDEO_SOURCE )
84135 if not cap .isOpened ():
85136 raise RuntimeError ("Unable to open video source. Set VIDEO_SOURCE correctly." )
0 commit comments