相机的事终于管用了,虽然第一个版本在技术上也"管用",如果你对"管用"的定义包括午餐前给我发43封邮件的话。
我在架子上夹了一个旧网络摄像头,对准了我的前门,一个在备用机器上跑的脚本在看着画面。一旦有东西通过镜头,它抓一个快照并邮件给我。不是安全公司,不是订阅,就是Python做着我要求它做的那一件事,最后终于成功了。
为什么我甚至想要这个
我大部分时间在楼上工作,门在楼下,远到送货敲门或者某人真的走上来的声音不一定能可靠地到我这儿。有几次包裹在外面放的比应该的久,有一次在天气不太对盒子的情况下。修复不需要聪明。它只需要告诉我那一刻有人在。
第一次尝试,不是它
我的第一个版本直接比较两帧,颜色,像素对像素,并把有多大的不同加起来。
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 = curr这在大约六分钟的白天测试中跑得很好,然后一旦太阳躲在云后面就彻底崩溃了。整个帧的亮度换了一点,每个像素都改变了一小点,加起来跨过一个整个彩色图像那就足够去打破我选的任何阈值了。我收到一封邮件。然后四秒钟后另一封,因为云还在动。然后一封因为一辆车的车灯在门廊上扫过那天晚上。这些都不是一个人。全是"整个帧亮了一点或暗了一点"。
我真正谷歌的东西
"opencv motion detection false positive lighting change"把我带到了一个pyimagesearch教程,这显然是互联网一半的人学基础运动检测的东西,读它让我感到有点傻,但这是有用的方式。两件事我完全漏掉了:
- 先转成灰度然后模糊再diff,所以单个像素的传感器噪声和小的亮度闪烁被平滑出去而不是计数
- 不是加起原始像素差异,而是把diff阈值变成一个黑白掩码,然后看连接的区域(轮廓),只关心大于某个最小大小的
第二个部分是真正重要的。一片云遮过去在一个地方改变一点亮度,这在阈值后大多消失了,因为足够小的改变永远不会通过阈值,首先。一个人通过镜头改变了一个大的、连续的像素补丁很多。一旦你作为区域看它而不是作为一个单独的加起来的数字,完全不同的形状。
真正管用的版本
背景处理首先。不是比较帧对帧,我抓一帧作为基准并模糊它:
import cv2
cap = cv2.VideoCapture(0)
_, first_frame = cap.read()
baseline = cv2.GaussianBlur(cv2.cvtColor(first_frame, cv2.COLOR_BGR2GRAY), (21, 21), 0)然后,每帧,模糊并对着那个基准diff而不是前一帧:
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]以及主循环,它也慢慢更新基准,所以长期的亮度漂移(早上到下午,不只是一片云过去)最后不会让一切看起来像运动永远:
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")那个addWeighted行在做一个缓慢的滚动平均,每个循环把基准nudge 5%朝着当前的帧而不是彻底代替它。慢到一个真实的人通过不会被吸收进基准在触发之前,渐进到太阳穿过天空走好几小时不会最后读作一个巨大的永久运动blob。
冷却,因为邮件提供商有意见
另一个花了一个晚上的部分:即使是正确的检测,一个人走过门三秒钟可以触发五个分别的邮件当他们通过多个帧。一个简单的冷却时间戳修复了那个。
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()邮件部分
smtplib和email.message不是令人兴奋的库(说实话有点笨拙,API感觉像它从2003年以后没变过,说起来大多数确实没有),但他们在约四行的样本有一旦样板整理了进我的收件箱里。
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)
前几个真实的告警直接进了垃圾,这我完全没算到。结果是一个账户只会发送,永不接收,没有正确设置的SPF记录,看起来完全像垃圾过滤器应该可疑的东西。花了SPF和DKIM记录在发送域之前Gmail停止安静地埋我自己的告警从我自己。
部署错误让我最尴尬
前一周这个"无人值守跑"在我笔记本上的一个终端窗口里,在一个常规登录会话中。这意味着那一次它真正重要的时候,一次我恰好关闭笔记本的小时的送货,整个事都和盖一起进了睡眠并从未看到一件事。无人值守在那句话里做了很多工作,但设置实际上没有备份。现在这是一个systemd服务在备用机器上,在崩溃时重新启动,存活我关闭任何东西。
为什么这个感觉不同
大多数我之前构建的是自包含的:一个待办应用,一个PDF工具,一个爬虫跑一次并打印东西。这个永远连续运行,无人值守(正确地,现在),并且真的必须是对的,或者它遗漏了我关心的东西或者邮件轰炸我有假警报。第一周它两个都做了,这是两种完全不同方式的烦人。
也有一个这个想法的版本我还没有构建,其中它区分一个人的形状blob从任何东西,代替任何充分大的移动区域。轮廓面积让我大部分方式,但一只大狗或一个经过风暴后摇晃的灌木都清除同大小的阈值一个人做的。那是一个真正更硬的问题,更接近真实的物体检测比帧差分,它坐在列表下面真的读CS50P的文件I/O讲座正确地代替就浏览它。
接下来是什么
一个冷却适应代替一个平坦的六十秒,所以一个人站在门口谈话不会在它过期时刻触发。以及,最后,人与蛾与灌木的问题,我怀疑以我最后学习一个真正的物体检测模型而结束代替永远倾靠帧差分。