85 lines
3.4 KiB
Python
Executable File
85 lines
3.4 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Webcam motion presence detector using frame differencing.
|
|
Exit codes:
|
|
0 = motion detected (user present)
|
|
1 = no motion (user likely away)
|
|
2 = camera unavailable (device node missing / gone) -> caller skips tick
|
|
3 = camera busy / in use (another app holds it) -> treat as present
|
|
Usage: presence_detect.py [camera_id]
|
|
|
|
Reads the physical camera directly, only for the ~0.5s it takes to grab a few
|
|
frames, then releases it — so a video call, howdy, etc. can use the camera the
|
|
rest of the time. When one of those apps IS holding the camera, we can't read
|
|
it; rather than mistake that for "no motion", we report it as busy/in-use (3),
|
|
which the daemon treats as presence: a camera in use means the user is here.
|
|
"""
|
|
import os
|
|
import sys
|
|
import time
|
|
import cv2
|
|
import numpy as np
|
|
|
|
FRAMES_TO_CHECK = 8
|
|
DIFFS_NEEDED = 2 # require motion in at least N consecutive-frame diffs
|
|
PIXEL_DELTA_THRESHOLD = 18 # per-pixel grayscale delta to count as "changed"
|
|
MOTION_AREA_RATIO = 0.008 # fraction of pixels that must change to call it motion
|
|
BLUR_KSIZE = (21, 21) # Gaussian blur kernel to suppress sensor noise
|
|
# Spacing between the frames we diff. Without it the frames are grabbed
|
|
# back-to-back within a few milliseconds, so slow, small movements barely differ
|
|
# between adjacent frames. Spacing the grabs out gives slow motion a real
|
|
# temporal baseline and guarantees each compared frame is a fresh one.
|
|
INTER_FRAME_DELAY = 0.06 # seconds between grabs (~0.5s total observation window)
|
|
|
|
|
|
def detect(camera_id: int) -> int:
|
|
# A missing device node means the camera is genuinely gone (unplugged, module
|
|
# not loaded) -> "unavailable" (2), and the daemon leaves presence untouched.
|
|
# A node that exists but won't open (below) or won't yield frames (further
|
|
# down) means something else is streaming it -> "busy/in use" (3) = present.
|
|
if not os.path.exists(f"/dev/video{camera_id}"):
|
|
return 2
|
|
|
|
cap = cv2.VideoCapture(camera_id)
|
|
if not cap.isOpened():
|
|
# Node exists but we can't open it: another app owns the camera.
|
|
return 3
|
|
|
|
motion_diffs = 0
|
|
frames_read = 0
|
|
prev_gray = None
|
|
try:
|
|
for i in range(FRAMES_TO_CHECK):
|
|
if i > 0:
|
|
time.sleep(INTER_FRAME_DELAY)
|
|
ok, frame = cap.read()
|
|
if not ok:
|
|
continue
|
|
frames_read += 1
|
|
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
|
gray = cv2.GaussianBlur(gray, BLUR_KSIZE, 0)
|
|
|
|
if prev_gray is not None:
|
|
delta = cv2.absdiff(prev_gray, gray)
|
|
changed = np.count_nonzero(delta > PIXEL_DELTA_THRESHOLD)
|
|
if changed / delta.size >= MOTION_AREA_RATIO:
|
|
motion_diffs += 1
|
|
|
|
prev_gray = gray
|
|
finally:
|
|
cap.release()
|
|
|
|
# Opened but produced no frames at all: on V4L2 a second opener can succeed
|
|
# yet have its reads starved while another app streams the device. Treat that
|
|
# as busy/in-use (present) rather than "no motion", so an active video call
|
|
# keeps the session awake even though we never see a usable frame.
|
|
if frames_read == 0:
|
|
return 3
|
|
|
|
return 0 if motion_diffs >= DIFFS_NEEDED else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
camera_id = int(sys.argv[1]) if len(sys.argv) > 1 else 0
|
|
sys.exit(detect(camera_id))
|