简介:面向有幼儿看护需求的家庭开发者,这份资源提供了一套基于Python、WebRTC与OpenCV的远程防摔床检测系统。系统包含后台服务,可通过手机或平板实时监测宝宝是否起床,一旦检测到爬动行为立即触发移动端报警,适合用作计算机视觉、物联网方向的课程设计,也适合Python基础较好的开发者进行家庭安防DIY。资源压缩包仅855KB,共10个文件,涵盖Python服务端与检测脚本、SVM识别模型(pkl)、HTML/JS前端交互页面、报警音效MP3及项目说明文档,模块划分清晰,可对照运行和二次开发。其中WebRTC负责视频流实时通信,OpenCV与预训练模型负责起床/爬动状态识别,前后端联动逻辑完整。目前已有476人学习/下载。整套方案包含可直接复现的远程监测逻辑,包括模型推理与实时传输的结合方式、前端报警触发机制以及完整目录结构;还可将SVM分类思路迁移到其他异常行为检测场景,节省从零搭建的时间。
1. 第一夜就报警的“防摔床”系统
把六个月大的小宝放在大床上,大人去客厅倒杯水,回来时最怕看到什么?不是哭,是孩子已经翻到床沿、半个身子悬空。市面上带围栏的防摔床垫并不便宜,而且等孩子会站了围栏反而成了翻越对象。这套基于python+webRTC+opencv实现的远程监测系统,思路很直接:手机或平板架在床尾,后台服务持续分析画面,检测到小宝从躺卧变成坐起或爬行姿势,立即触发手机报警,给家长争取到从客厅跑回卧室的十几秒。它不是智能家居套装,而是一份课程设计级别的完整工程,包含信令服务、浏览器客户端、OpenCV检测器和SVM分类模型,适合有Python和前端基础的人二次开发。如果你正打算做一个“能真正跑起来”的计算机视觉项目,这个项目值得拆开看一遍。
2. 媒体面与控制面分离:WebRTC 信令与视频通道的搭建
2.1 bootstrap.py 为什么必须独立于视频通道
整个系统里最容易让人误会的文件是bootstrap.py。它不是启动脚本,而是 WebRTC 的信令服务端。WebRTC 本身只管点对点传视频,但两个浏览器之间要建立连接,必须先交换 SDP(Session Description Protocol)和 ICE 候选信息,这个交换过程需要一个中转通道,也就是信令服务器。项目里的client.js负责构造 RTCPeerConnection,bootstrap.py用 WebSocket 接收并转发这些描述信息。
打开bootstrap.py可以看到典型的 WebSocket 信令循环:
import asyncio import websockets import json clients = set() async def signaling(websocket, path): clients.add(websocket) try: async for message in websocket: data = json.loads(message) if data.get("type") == "offer": # 将offer转发给另一个客户端 for client in clients: if client != websocket: await client.send(json.dumps(data)) elif data.get("type") == "answer": for client in clients: if client != websocket: await client.send(json.dumps(data)) elif data.get("type") == "candidate": for client in clients: if client != websocket: await client.send(json.dumps(data)) finally: clients.remove(websocket) start_server = websockets.serve(signaling, "0.0.0.0", 8765) asyncio.get_event_loop().run_until_complete(start_server) asyncio.get_event_loop().run_forever()这段代码做的事就是“转发”。clients集合维护当前在线端点,收到offer就广播给另一方,candidate也一样。监听地址写0.0.0.0而不是127.0.0.1,是为了让局域网内的手机能够访问到这台运行信令服务的机器,只有当手机端和电脑端都在同一网段时这套机制才成立。8765 端口可以换,但换了之后client.js里的连接地址要同步修改。
这里要强调一点:信令服务不传视频数据,视频走的是 P2P 通道。为什么特意区分?因为如果把视频流也经服务器中转,延迟和带宽占用都会翻倍。在家用场景里,手机和电脑都连着同一个路由器,P2P 直连的延迟可以压到 100ms 以内,比服务端转发方案实时得多。
2.2 client.js 里的 RTCPeerConnection 关键参数
浏览器端的client.js是这个项目的核心前端逻辑。它要做两件事:采集摄像头画面并发送,同时接收对端画面用于显示。WebRTC 的getUserMedia负责采集,RTCPeerConnection负责传输。实际部署时,手机或平板作为采集端挂在床尾,电脑浏览器作为观察端接收画面,所以两端角色不同,但client.js里通过一个简单的标志位区分。
核心代码段如下:
const peerConnection = new RTCPeerConnection({ iceServers: [ { urls: "stun:stun.l.google.com:19302" } ] }); const constraints = { video: { width: { ideal: 640 }, height: { ideal: 480 }, frameRate: { ideal: 15 } }, audio: false }; navigator.mediaDevices.getUserMedia(constraints) .then(stream => { stream.getTracks().forEach(track => { peerConnection.addTrack(track, stream); }); const videoElement = document.getElementById("localVideo"); videoElement.srcObject = stream; }) .catch(err => console.error("Camera access denied:", err)); peerConnection.onicecandidate = (event) => { if (event.candidate) { socket.send(JSON.stringify({ type: "candidate", candidate: event.candidate })); } };iceServers里的 STUN 服务器用于 NAT 穿透。在同一个局域网里,即使没有 STUN 服务器也能连通,因为 WebRTC 会优先尝试 host 类型的候选地址。如果部署环境跨网络,比如手机用 4G、电脑在公司,那就必须配置 TURN 服务器,否则 P2P 连接大概率失败。本项目定位是居家监测场景,stun:stun.l.google.com:19302这个公共 STUN 地址已经够用,但要注意公共 STUN 只负责发现公网地址,不转发流量。
width和height设置成理想值 640×480,而不是强制值,是因为不同手机摄像头的原生分辨率不一样。这个设置告诉浏览器“优先用这个规格”,但底层相机驱动不满足时也可以降级。frameRate设 15 而不是 30,能显著降低编码负载和带宽占用,监测场景不需要高帧率,孩子起床的动作幅度足够大,15fps 完全够检测算法捕捉。
3. 从 HOG 到 SVM:digits_svm.pkl 的人体姿态分类与起床判定
3.1 train.py 如何得到 digits_svm.pkl
项目里的digits_svm.pkl是一个 OpenCV 自带的数字识别 SVM 模型文件,train.py则负责训练这个模型。这套逻辑最早来自 OpenCV 官方示例:用digits.png数据集的 5000 个手写数字样本,提取 HOG(Histogram of Oriented Gradients,方向梯度直方图)特征,训练 SVM 分类器。digits_svm.pkl本身识别的对象是 0 到 9 的单个数字。这里有个容易困惑的地方:为什么检测小宝起床要用数字识别模型?
实际情况是:如果项目打包时只交付了digits_svm.pkl,那它做的是“最邻近基线验证”,验证检测流程能否跑通。正式判定起床用的是 OpenCV 的轮廓分析加位置变化,不是拿手写数字去识别小孩。但train.py的价值在于示范了一条完整的“特征提取→训练→序列化→加载推理”的流水线,你完全可以把数字分类器换成“坐起 vs 躺卧”的二分类模型。
train.py里的训练流程一般是这样:
import cv2 import numpy as np import pickle from sklearn.svm import SVC cells = [np.float32(row) for row in np.array_split(cv2.imread('digits.png', 0), 50)] digits = [np.float32(col) for row in cells for col in np.array_split(row, 50)] features = [] labels = [] for i, digit in enumerate(digits): # 对每个样本提取HOG描述子 hog = cv2.HOGDescriptor((20, 20), (10, 10), (5, 5), (5, 5), 9) features.append(hog.compute(digit).ravel()) labels.append(i // 50) model = SVC(kernel='linear', C=1.0) model.fit(np.array(features), np.array(labels)) with open('digits_svm.pkl', 'wb') as f: pickle.dump(model, f)cv2.HOGDescriptor((20,20), (10,10), (5,5), (5,5), 9)的参数依次是:检测窗口大小 20×20、块大小 10×10、块步长 5×5、细胞单元大小 5×5、梯度方向直方图的 bin 数为 9。窗口和块决定了特征维度的粒度,块越小特征越细,但计算量也越大。在 20×20 的小图上,这组参数是经验值,既能区分不同数字结构,又不会导致特征维度过高。核函数用linear的 C-SVC,对 5000 个样本来说,线性核已经足够,用 RBF 核反而容易过拟合且推理更慢。
保存用pickle.dump而不是cv2.svm.save,是因为 sklearn 的对象序列化后可以直接拿到predict方法返回值,方便和后续代码集成。加载时用pickle.load配合joblib版本匹配,一般不会出问题。
3.2 video_transform_track.py 的检测主循环
video_transform_track.py是真正的检测主力。它做的事情是:读入视频帧,做背景减除或帧差,找到运动轮廓,再根据轮廓位置、面积、宽高比判断是否是人起床。代码里能看到一个典型的轮廓判定流程:
import cv2 cap = cv2.VideoCapture(0) # 背景减法器,history取500,阈值取16 fgbg = cv2.createBackgroundSubtractorMOG2( history=500, varThreshold=16, detectShadows=True ) min_area = 3000 # 最小轮廓面积,过滤噪声 max_y_ratio = 0.5 # 轮廓顶部超过画面50%高度时才报警 while True: ret, frame = cap.read() if not ret: break mask = fgbg.apply(frame) mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, np.ones((5, 5), np.uint8)) contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) for cnt in contours: area = cv2.contourArea(cnt) if area < min_area: continue x, y, w, h = cv2.boundingRect(cnt) # 起床时躯干抬升,外接框顶部明显上移 if y < int(frame.shape[0] * max_y_ratio): print(f"ALARM: person rising, bbox=({x},{y},{w},{h})") # 这里触发service.py的报警逻辑createBackgroundSubtractorMOG2的history=500意味着用过去 500 帧建立背景模型。如果摄像头架设位置固定、光线稳定,这个值越小响应越快,但前期会产生较多误报;如果环境经常有光线变化(比如窗帘飘动),history 大一些更稳。varThreshold=16是像素值变化的判定阈值,数值越小对变化越敏感,同时噪声也越多。detectShadows=True会单独标记阴影区域,这类区域在mask里灰度值偏暗,可以用阈值再过滤。
max_y_ratio=0.5是判断“起身”的关键逻辑。婴儿躺下时,人体外接框的顶部在画面中下部;坐起或爬行时,躯干抬高,外接框顶部会上移。y < frame.shape[0] * 0.5表示外接框顶部进入了画面上半区,此时大概率已经坐起来了。如果床很小、孩子靠近摄像头,这个值要调大到 0.6 或 0.7,否则一翻身就误报。
morphologyEx开运算是先腐蚀后膨胀,用来消除 mask 里的孤立噪点。(5,5)的核在 640×480 分辨率下适中,核太大会把细小的手臂轮廓也抹掉,核太小又压不住噪声。这是调参时最先该动的两个参数之一。
3.3 检测参数速查表
| 参数 | 推荐值 | 影响 |
|---|---|---|
| history | 300~800 | 越大越适应缓慢光线变化,越小前景响应越快 |
| varThreshold | 10~30 | 越小越敏感,越小越容易把阴影误判为前景 |
| min_area | 2000~5000 | 过滤小面积噪点,值越大越不易误报 |
| max_y_ratio | 0.4~0.7 | 判定“坐起”的高度阈值,需要按实际机位标定 |
| 开运算核 | (3,3)~(7,7) | 越大轮廓越平滑,但细小肢体动作会丢失 |
| frameRate | 10~20 | 越低功耗越小,太高对检测没有额外收益 |
这些值没有一个绝对正确的组合,换一个房间、换一盏灯,都要重调。我一般会在上午和晚上各录制两分钟视频,离线跑一遍检测,看误报率和漏报率再定参数。项目代码里留了这些常量在文件头部,这是设计得不错的地方。
4. 报警状态机:service.py 的防误报与手机端告警实战
4.1 条件触发与持续确认
service.py负责把检测结果变成用户能感知的报警。直接对每一帧判定的结果是不可用的,因为孩子睡觉翻个身、手臂抬一下,都会让轮廓面积和高度产生波动,一帧的异常就会触发报警。实际工程里必须在video_transform_track.py的判定之上加一个状态机。
状态机包含三个状态:ARMED(监控中)、TRIGGERED(检测到疑似起床)、ALERTING(确认报警)。在ARMED状态下,连续 N 帧满足“外接框顶部过线且面积超过阈值”才进入TRIGGERED;在TRIGGERED状态下,如果 M 帧内仍然持续满足条件,则进入ALERTING并播放alert.mp3;如果中途条件不满足,回退到ARMED状态。这个 N、M 参数非常关键,N=10 表示需要连续 10 帧(约 0.7 秒)持续符合条件才过渡,基本排除单帧噪声。
伪代码对应关系:
import threading import time import winsound class AlertStateMachine: def __init__(self, confirm_frames=10, alert_hold_frames=5): self.state = "ARMED" self.confirm_frames = confirm_frames self.alert_hold_frames = alert_hold_frames self.frame_counter = 0 self.alerting = False def update(self, rising_detected): if self.state == "ARMED": if rising_detected: self.frame_counter += 1 if self.frame_counter >= self.confirm_frames: self.state = "TRIGGERED" self.frame_counter = 0 else: self.frame_counter = 0 elif self.state == "TRIGGERED": if rising_detected: self.frame_counter += 1 if self.frame_counter >= self.alert_hold_frames: self.state = "ALERTING" self._fire_alert() else: self.state = "ARMED" self.frame_counter = 0 def _fire_alert(self): def play(): # Windows下用winsound,Linux下用ffplay或aplay winsound.PlaySound("alert.mp3", winsound.SND_FILENAME) threading.Thread(target=play, daemon=True).start()winsound.PlaySound是阻塞调用,必须放到独立线程里,否则报警期间检测循环会卡住,后续帧全部积压,延迟越来越高。代码里用threading.Thread(target=play, daemon=True).start()就是为了解决这个问题。
服务端确认报警后,还需要把信号推送到手机端。service.py里向client.js所在的浏览器页面发送一个自定义消息,浏览器收到之后播放alert.mp3并震动:
socket.onmessage = (event) => { const data = JSON.parse(event.data); if (data.type === "alert") { const audio = new Audio("alert.mp3"); audio.play(); if (navigator.vibrate) { navigator.vibrate([500, 200, 500]); } } };navigator.vibrate([500, 200, 500])让手机振动 500 毫秒、停 200 毫秒、再振 500 毫秒,这是 Android Chrome 的标准写法,iOS Safari 不支持此 API,但播放声音已经足够引起注意。
4.2 把告警接进 service.py 的完整链路
service.py里既包含 WebSocket 服务端(负责和前端通信),也包含检测线程的启动入口。实战中把video_transform_track.py的检测结果以回调方式传给状态机,从而保证告警线程和检测线程解耦。这样做的好处是:即使浏览器页面意外崩溃,后台检测和报警逻辑仍然在独立进程里运行,不会因为前端问题而彻底失效。这是监控类项目里容易被忽略的健壮性设计——服务端才是真正的防线,浏览器只是显示层。
5. 部署到卧室前的最后 10 分钟:参数验证与常见坑
5.1 摄像头 IP 与权限的坑
部署时最隐蔽的问题是cv2.VideoCapture(0)的0指向的是树莓派或电脑的本地摄像头,但手机作为采集端时,视频源在手机浏览器里,电脑端 OpenCV 拿不到手机摄像头数据。项目的实际工作方式是:手机浏览器负责采集并通过 WebRTC 传给电脑端显示和检测。但video_transform_track.py里如果直接读取本地摄像头,则需要保证电脑本机有摄像头。两种方案二选一时,我建议优先确认电脑端视频源,不要默认VideoCapture(0)可用。笔记本有内置摄像头,但台式机没有,需要改用 USB 摄像头或者把检测端放到树莓派上。
手机浏览器第一次访问页面时,会弹出摄像头授权请求,必须点允许。如果误点拒绝,需要在浏览器设置里重置权限。iOS 的 Safari 对 WebRTC 支持目前还不算完美,实测中 Android Chrome 的兼容性最好,推荐优先用 Android 手机做采集端。
5.2 找一条最稳的判定线
把max_y_ratio定在 0.5 是稳妥起点,但每个床的高度和摄像头架设角度差异很大。有个简单的标定方法:打开系统的视频预览窗口,让孩子平躺,记录此时外接框顶部 y 坐标的平均值;再让孩子坐起,记录另一组 y 值。把这两个值的中间数作为判定阈值。这样标定出来不会出现平躺时误报、坐起时漏报的边界问题。
如果video_transform_track.py里能看到 detection 窗口的实时绘制,这一步只需要一分钟。我一般会在项目里加一段调试代码,用cv2.rectangle和cv2.putText把外接框和当前 y 阈值画在画面上,然后对着屏幕调整摄像头的俯仰角。
5.3 运行验证清单
python bootstrap.py # 启动信令服务 python service.py # 启动检测与告警服务 python train.py # 本地重新训练SVM(可选)启动顺序有讲究:先bootstrap.py,再service.py,最后打开浏览器访问client.js所在页面。反过来先开浏览器会导致 WebSocket 连接失败几次后才自动重连,造成“页面一直转圈”的假象。验证是否全部就绪,看三个点:信令服务窗口打印出 WebSocket 客户端连接日志;service.py 的检测窗口弹出实时画面;浏览器页面上能看到采集端的视频流并伴随检测框。
5.4 误报率降不下去时的调试路径
一晚误报超过三次,先把开运算核从(5,5)改到(9,9),同时把min_area上调到 4000,这两种手段能过滤掉大部分因为婴儿踢被子产生的小面积轮廓变动。若仍然误报,问题多半出在光照上,晚上开夜灯下的影子会被 MOG2 当成运动前景。此时把detectShadows设为 False,mask中原本标记为灰色的阴影区域会被直接当作背景,误报能明显下降。代价是目标本身的阴影部分也会被忽略,轮廓面积会缩小,需要降低min_area来做平衡。这套跳变调参法能覆盖绝大多数民用场景的误报来源。
本文还有配套的精品资源,点击获取