Anyone who has ever set up consumer IP security cameras knows the frustration of built-in motion alerts.
A gentle gust of wind shaking a tree branch, passing headlights casting shadows across a driveway, or a single spiderweb floating in front of the lens will happily trigger 200 high-priority phone notifications at 3:00 AM.
After wrestling with the limitations of stock Reolink camera firmware and the heavy database/CPU bloat of full NVR suites like ZoneMinder, I wanted something lean, reliable, and headless that would sit quietly inside a lightweight Proxmox LXC container without chewing up server resources.
To solve this for my home lab setup, I built CCTV-Watch — a lightweight, headless Python daemon that ingests raw RTSP camera streams, applies OpenCV computer vision filtering to eliminate environmental noise, and sends instant webhook alerts with snapshot attachments only when genuine motion occurs.
The CCTV-Watch processing pipeline: from headless RTSP ingestion to OpenCV frame differencing and webhook alerts
The Problem with Simple Pixel Changes
Off-the-shelf cameras often rely on basic global pixel luminance shifts to detect motion. If a cloud passes over the sun, every pixel in the frame changes brightness, and the camera falsely flags an intruder.
To filter out environmental noise without running heavy deep-learning object detection models that would melt a low-power home server, CCTV-Watch uses a 3-stage computer vision pipeline:
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Raw RTSP Frame │ ---> │ GrayScale & │ ---> │ cv2.absdiff │ ---> │ Contour Area │
│ (Subsampled 5fps)│ │ Gaussian Blur │ │ Background Delta│ │ Threshold Filter│
└─────────────────┘ └─────────────────┘ └─────────────────┘ └─────────────────┘
│
▼
┌─────────────────┐
│ Webhook Alert & │
│ Snapshot JPEG │
└─────────────────┘
Key Technical Implementation
1. Headless RTSP Ingestion & Connection Watchdog
IP camera RTSP feeds frequently drop or hang on consumer Wi-Fi/Ethernet switches. The daemon uses OpenCV’s VideoCapture wrapped in a resilient watchdog loop (qstart.sh & main.py) that auto-reconnects on frame timeouts:
import cv2
import time
def open_rtsp_stream(rtsp_url):
cap = cv2.VideoCapture(rtsp_url)
cap.set(cv2.CAP_PROP_BUFFERSIZE, 2)
return cap
2. Frame Differencing & Gaussian Smoothing
To ignore rapid pixel jitter and camera sensor noise, each incoming frame is converted to grayscale and smoothed with a (21, 21) Gaussian blur kernel. We then compute the absolute difference against an exponentially decaying background reference frame:
def process_frame(frame, background_frame, min_area=5000):
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
gray = cv2.GaussianBlur(gray, (21, 21), 0)
if background_frame is None:
return gray, False, None
# Calculate absolute delta between current frame and background
frame_delta = cv2.absdiff(background_frame, gray)
thresh = cv2.threshold(frame_delta, 25, 255, cv2.THRESH_BINARY)[1]
thresh = cv2.dilate(thresh, None, iterations=2)
# Find contours
contours, _ = cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
motion_detected = any(cv2.contourArea(c) > min_area for c in contours)
return gray, motion_detected, contours
3. Cooldown State Machine & Webhook Dispatch
When genuine contours exceeding the min_area threshold are confirmed across consecutive frames, CCTV-Watch saves a timestamped snapshot JPEG and dispatches an instant webhook payload to a private notification channel.
A 60-second cooldown timer prevents duplicate alert floods while the subject remains in frame.
[2024-11-20 03:14:02] [INFO] Connecting to RTSP stream: rtsp://192.168.1.140:554/live/ch0
[2024-11-20 03:14:03] [INFO] Stream established (1920x1080 @ 25fps) -> Subsampling to 5.0 FPS
[2024-11-20 03:14:04] [INFO] Background model initialized. Baseline delta: 0.00%
[2024-11-20 03:17:45] [DEBUG] Motion candidate detected: Area 1420 px (Threshold 5000 px) -> Filtered (Wind/Leaves)
[2024-11-20 03:22:11] [ALERT] Motion confirmed: Area 12450 px | Contours: 2 | Frames: 4/4
[2024-11-20 03:22:11] [INFO] Saved snapshot: /var/log/cctv/snapshot_20241120_032211.jpg
[2024-11-20 03:22:12] [INFO] Webhook dispatched (HTTP 200 OK) -> Entering 60s cooldown
Results & Takeaways
Running headlessly as a background systemd daemon on Linux, CCTV-Watch uses less than 4% CPU on a modest virtual machine while monitoring multiple 1080p camera feeds at 5 FPS.
Most importantly, false-positive alerts dropped by over 95%, transforming noisy camera streams into an actionable security feed.
If you are interested in physical sensor hardware, check out my earlier build guide on building a Raspberry Pi PIR movement detection CCTV system or learn how to run background services with creating simple systemd service units on Linux.