So the camera thing finally works, after a first version that technically also "worked" if your definition of working includes emailing me forty-three times before lunch.
I've got an old webcam clipped to a shelf pointed at my front door, and a script running on a spare machine that watches the feed. The moment something moves through frame it grabs a snapshot and emails it to me. Not a security company, not a subscription, just Python doing exactly the one thing I asked it to, eventually.
why I even wanted this
I work upstairs most days, and the door is downstairs, far enough that a delivery knock or someone actually walking up doesn't reliably reach me. Packages sat outside longer than they should have a couple of times, once in weather that did the box no favors. The fix didn't need to be clever. It just needed to tell me the moment someone was there.
attempt one, which was not it
My first version compared two frames directly, in color, pixel by pixel, and summed up how different they were.
import cv2
cap = cv2.VideoCapture(0)
_, prev = cap.read()
while True:
_, curr = cap.read()
diff = cv2.absdiff(prev, curr)
total_diff = diff.sum()
if total_diff > 500000:
send_alert(curr)
prev = currThis ran fine for about six minutes of daylight testing and then fell apart the second the sun moved behind a cloud. The whole frame's brightness shifted a little, every pixel changed by a small amount, and summed across an entire color image that's plenty to blow past any threshold I picked. I got an email. Then another one four seconds later because the cloud kept moving. Then one because a car's headlights swept across the porch that evening. None of these were a person. All of them were "the whole frame got slightly brighter or darker."
what I actually googled
"opencv motion detection false positive lighting change" got me to a pyimagesearch tutorial that's apparently the thing half the internet learns basic motion detection from, and reading it made me feel a little dumb in a useful way. Two things I'd missed entirely:
- Convert to grayscale first and blur it before diffing, so single-pixel sensor noise and small lighting flicker gets smoothed out instead of counted
- Instead of summing raw pixel differences, threshold the diff into a black-and-white mask, then look at connected regions (contours) and only care about ones bigger than some minimum size
That second part is the one that actually mattered. A cloud passing over changes brightness everywhere a tiny bit, which after thresholding mostly disappears since a small enough change never crosses the threshold in the first place. A person walking through frame changes a big, contiguous patch of pixels a lot. Different shape entirely once you're looking at it as regions instead of a single summed number.
the version that actually works
Background handling first. Instead of comparing frame to frame, I grab one frame as a baseline and blur it:
import cv2
cap = cv2.VideoCapture(0)
_, first_frame = cap.read()
baseline = cv2.GaussianBlur(cv2.cvtColor(first_frame, cv2.COLOR_BGR2GRAY), (21, 21), 0)Then, per frame, blur and diff against that baseline instead of the previous frame:
def detect_regions(frame, baseline, min_area=2000):
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
gray = cv2.GaussianBlur(gray, (21, 21), 0)
diff = cv2.absdiff(baseline, gray)
thresh = cv2.threshold(diff, 25, 255, cv2.THRESH_BINARY)[1]
thresh = cv2.dilate(thresh, None, iterations=2)
contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
return [c for c in contours if cv2.contourArea(c) > min_area]And the main loop, which also slowly updates the baseline so long-term lighting drift (morning to afternoon, not just a passing cloud) doesn't eventually make everything look like motion forever:
while True:
_, frame = cap.read()
regions = detect_regions(frame, baseline)
if regions:
send_alert(frame)
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
gray = cv2.GaussianBlur(gray, (21, 21), 0)
baseline = cv2.addWeighted(gray.astype("float"), 0.05, baseline.astype("float"), 0.95, 0).astype("uint8")That addWeighted line is doing a slow rolling average, nudging the baseline 5% toward the current frame every loop instead of replacing it outright. Slow enough that an actual person passing through doesn't get absorbed into the baseline before triggering, gradual enough that the sun crossing the sky over hours doesn't eventually read as one giant permanent blob of motion.
a cooldown, because email providers have opinions
The other piece that took an evening: even with proper detection, one person walking past the door for three seconds could trigger five separate emails as they crossed multiple frames. A simple cooldown timestamp fixed that.
import time
last_alert = 0
COOLDOWN_SECONDS = 60
def maybe_alert(frame):
global last_alert
if time.time() - last_alert < COOLDOWN_SECONDS:
return
send_alert(frame)
last_alert = time.time()the email part
smtplib and email.message are not exciting libraries (a bit clunky honestly, the API feels like it hasn't changed since 2003, which tbh it mostly hasn't), but they get a message with an attached JPEG into my inbox in about four lines once the boilerplate is sorted.
import smtplib
from email.message import EmailMessage
def send_alert(image_bytes):
msg = EmailMessage()
msg["Subject"] = "motion detected"
msg["From"] = "camera@example.com"
msg["To"] = "bjorn@example.com"
msg.set_content("Something moved in frame.")
msg.add_attachment(image_bytes, maintype="image", subtype="jpeg", filename="frame.jpg")
with smtplib.SMTP_SSL("smtp.example.com", 465) as server:
server.login("camera@example.com", "app-password-not-real")
server.send_message(msg)
First few real alerts went straight to spam, which I hadn't accounted for at all. Turns out an email account that only ever sends, never receives, with no SPF record set up properly, looks exactly like something a spam filter should be suspicious of. Took an SPF and DKIM record on the sending domain before Gmail stopped quietly burying my own alerts from myself.
the deployment mistake that embarrassed me the most
For the first week this "ran unattended" inside a terminal window on my laptop, in a regular login session. Which meant the one time it actually mattered, a delivery at an hour I happened to have my laptop closed for, the whole thing had gone to sleep along with the lid and never saw a thing. Unattended was doing a lot of work in that sentence that the setup didn't actually back up. It's a systemd service on the spare machine now, restarts on crash, survives me closing anything.
why this one felt different
Most of what I'd built before this was self-contained: a todo app, a PDF tool, a scraper that runs once and prints something. This one runs continuously, forever, unattended (properly, now), and has to actually be right, or it either misses the thing I care about or spams me with false alarms. First week it did both, which was annoying in two completely different ways.
There's also a version of this idea I haven't built yet, where it distinguishes a person-shaped blob from anything else, instead of any sufficiently large moving region. Contour area gets me most of the way, but a big dog or a swaying bush after a storm both clear the same size threshold a person does. That's a genuinely harder problem, closer to real object detection than frame differencing, and it's sitting on the list below actually reading CS50P's file I/O lecture properly instead of skimming it.
what's next
A cooldown that adapts instead of a flat sixty seconds, so a person standing at the door talking doesn't retrigger the moment it expires. And, eventually, the person-versus-moth-versus-bush problem, which I suspect ends with me finally learning a proper object detection model instead of leaning on frame differencing forever.