#!/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))