简介:面向高校毕设与课设场景的室内老人摔倒检测系统项目资源,适用于计算机、电子信息工程、数学等专业学生,聚焦前端交互、后端服务与摔倒检测算法的完整实现,可支撑课程设计、期末大作业或毕业设计实践。压缩包共110个文件,以99个Java源文件为主体,涵盖用户、设备、管理员等业务逻辑;XML配置用于持久层映射,YAML配置管理环境参数,SQL脚本可初始化数据库,整体仅148KB,结构紧凑,便于快速定位核心代码。已有47人学习该资源。内容中可见用户服务、设备管理、管理员服务、文件工具、密码加密、通知配置等模块,摔倒检测算法部分针对图像/传感器数据实时分析,注重误判率控制与光线、遮挡等环境鲁棒性,为读者提供从界面到后端再到算法的完整参考,适合作为进一步开发与研究的基础。
1. 室内老人摔倒检测系统:一个毕设级全栈方案到底要做什么
独自生活的老人如果摔倒,最大的风险不是摔伤本身,而是摔倒后长时间无人发现。室内老人摔倒检测系统就是把“发现”这件事自动化的一套组合方案:前端负责看和通知,后端负责转发和留痕,摔倒检测算法负责在画面里判断“人倒了”还是“只是弯腰”。如果你正在做毕设或课设,这套东西的妙处在于它同时覆盖了计算机视觉、后端接口、前端交互三个方向,一个项目能交代三拨问题。前提是你得把算法这关先守住——摔倒检测真正的难点不是“检测”,而是“别误报”:老人弯腰捡东西、坐下速度快一点,都可能把系统带翻车。
2. 摔倒检测算法:姿态估计 + 摔倒判据,别一上来就碰行为识别
2.1 为什么选姿态估计而不是目标检测或行为识别
很多同学拿到题目第一反应是“我用 YOLO 检测摔倒”,但目标检测只能输出一个矩形框,框里的人是站着还是躺着它说不清。老人弯腰捡东西和倒地瞬间,外接框的宽高比都可能突变,误报率会高到你不敢开声音告警。行为识别(ST-GCN、TSM 这类)效果确实好,但它需要摔倒视频数据集、需要训练时间、需要调参,课设和毕设的时间预算根本吃不消。
姿态估计是性价比最高的路线。MediaPipe Pose 这类工具直接输出人体 33 个关键点的坐标,有了坐标就能算出三个核心物理量:躯干与竖直方向的夹角、人体中心点的垂直速度、倒地后保持不动的持续时间。摔倒的本质就是这三个量同时出现异常。用规则判据而不是训练分类器,意味着你不用收集数据集、不用训练,跑通一条视频流就能有七成效果,剩下的时间全花在调阈值上,这个投入产出比是行为识别没法比的。
2.2 最小可行实现:用 MediaPipe 提取骨架关键点
下面这套代码是算法部分的最小骨架。我一般先用它跑通单张图片,确认关键点能稳定出来,再整合进视频流。
import cv2 import mediapipe as mp import numpy as np mp_pose = mp.solutions.pose # model_complexity: 0 最快但漂移大,1 平衡,2 最准但慢 # min_detection_confidence: 调高减少误检,但低照度更容易漏检 pose = mp_pose.Pose( static_image_mode=False, model_complexity=1, min_detection_confidence=0.6, min_tracking_confidence=0.5 ) KEY_SHOULDER = (11, 12) # 左右肩 KEY_HIP = (23, 24) # 左右髋 KEY_NOSE = 0 # 鼻尖,用于辅助判断 def extract_keypoints(frame_rgb): result = pose.process(frame_rgb) if not result.pose_landmarks: return None lm = result.pose_landmarks.landmark # 取左右肩、左右髋的中点,减少单点抖动影响 shoulder = np.array([(lm[11].x + lm[12].x) / 2, (lm[11].y + lm[12].y) / 2]) hip = np.array([(lm[23].x + lm[24].x) / 2, (lm[23].y + lm[24].y) / 2]) nose = np.array([lm[KEY_NOSE].x, lm[KEY_NOSE].y]) return {"shoulder": shoulder, "hip": hip, "nose": nose} def fall_judge(features, prev_features, frame_height): if features is None or prev_features is None: return False, 0.0 # 髋部中点垂直位移,除以画面高度做归一化,避免分辨率影响 hip_velocity = (features["hip"][1] - prev_features["hip"][1]) / frame_height # 躯干向量与竖直方向的夹角,左右方向都取绝对值 torso_vec = features["shoulder"] - features["hip"] angle = np.degrees( np.arctan2(abs(torso_vec[0]), abs(torso_vec[1])) ) fall_candidate = ( hip_velocity > 0.35 # 垂直下落速度阈值 or angle > 55.0 # 躯干倾角阈值 ) return fall_candidate, angle逻辑说明:extract_keypoints里取左右肩、左右髋的中点而不是单侧点,是为了抵消单侧关节抖动带来的误差,这个细节在低照度下特别重要。fall_judge里我用了两个判据——髋部垂直速度和躯干倾角,取“或”的关系,目的是让“快速瘫倒”和“缓慢滑倒”两种模式都能被捕捉到。垂直速度用画面高度归一化,这样 480p 和 1080p 的视频用同一套阈值得出同样的结论,你不用为每个摄像头单独调一遍参数。
参数说明:hip_velocity的单位是“画面高度的百分比/帧”,0.35 表示一帧内髋部下移了画面高度的 35%,对应 30FPS 视频里大约 0.5 秒内完成摔倒的动作。angle是躯干与竖直方向的夹角,正常人站立时 5~15 度,弯腰捡东西可能到 50~60 度,真正倒地时接近 80~90 度,所以 55 度是一个“宁可多报、不可漏报”的折中。
2.3 摔倒判据的 4 个关键参数和相机安装位置
单帧判据只是候选,连续帧确认才是降低误报的关键。我一般把判据拆成四级状态:正常 → 疑似(连续 N 帧触发判据) → 确认(疑似超过 M 秒) → 告警(确认后静止 S 秒)。这四级状态里的参数全部可调,下面是经验区间。
| 参数 | 建议区间 | 调低会怎样 | 调高会怎样 |
|---|---|---|---|
| 垂直速度阈值 | 0.25~0.4(归一化/帧) | 快速坐下被误判为摔倒 | 真倒下但下坠慢时会漏报 |
| 躯干倾角阈值 | 40°~60° | 低头看手机会误报 | 侧摔、靠墙滑倒漏报 |
| 确认帧数 | 5~15 帧 | 轻微抖动就进入告警流程 | 倒地后响应变慢 |
| 倒地静止时间 | 5~10 秒 | 老人起身慢也会触发告警 | 晕倒无动静时迟迟不报 |
相机安装位置对参数的影响比想象中大。顶装摄像头(天花板往下拍)时躯干倾角基本失去意义,因为人的长度方向在画面里是向下收缩的,这时候主力判据应该是“身体中心点高度下降 + 身体面积变化”。斜装摄像头(高度 1.8~2.2 米,向下倾斜 30~45 度)是最好调的安装方式,倾角和速度判据都有效。我在实验室里固定用斜装,因为教室和养老示范间的天花板高度都支持这种装法,参数不用做两套。
3. 后端服务:FastAPI + WebSocket,把告警实时推给前端
3.1 为什么用 FastAPI,而不是 Spring Boot 或 Flask
后端选型这件事,我见过太多同学一上来就奔若依这类前后端分离脚手架去,理由是“网上教程多、后台管理页面现成”。但若依本质是一套通用后台管理系统,权限、用户、菜单全给你了,可你要的是“接算法、推视频流、推告警”,这些在若依里一样要自己写,还得先学会它的代码生成器和多模块结构,时间全耗在框架上了。
我的建议是:这套系统后端用 FastAPI。理由有三个。第一,算法推理是 Python 写的,后端也用 Python 就没必要把推理单独拆一个服务,进程内直接调用。第二,FastAPI 自带 OpenAPI 交互文档,答辩演示时浏览器打开/docs就能看到所有接口的请求响应样例,这个效果比贴 PPT 强得多。第三,Flask 是同步模型,处理 WebSocket 长连接要额外挂库,FastAPI 原生支持异步 WebSocket,少一层麻烦。只有一种情况我会换 Spring Boot——学校明确要求 Java 技术栈,那没得选,但也要用 HTTP 调算法服务的方式把 Python 推理独立出去。
3.2 项目结构、MJPEG 视频流接口与告警 REST API
后端目录我习惯拆成四个文件,不搞复杂分层,因为项目体量就这么多,分层多了反而增加理解成本。
backend/ ├── app.py # FastAPI 入口,挂路由和中间件 ├── detector.py # 摔倒检测算法封装(上一章的代码放这里) ├── camera.py # 摄像头采集、全局帧缓存 └── alerts.py # 告警记录的内存存储和查询摄像头采集的核心问题是:cv2.VideoCapture.read()是阻塞的,如果每次请求都现场读一帧,两个浏览器窗口同时打开视频流就会互相抢摄像头。常见做法是启动一个后台线程循环读帧,把最新一帧压缩成 JPEG 字节存到全局变量,接口只负责把这个全局变量发给请求方。
# app.py 关键片段 from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse import camera app = FastAPI() # 前端开发服务器通常跑在 5173,后端在 8000,跨域必须开 app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:5173"], allow_methods=["*"], allow_headers=["*"], ) @app.get("/video_feed") def video_feed(): def generate(): while True: frame_jpg = camera.get_latest_frame() if frame_jpg is None: continue # multipart 格式是 MJPEG 流固定的协议 yield (b"--frame\r\n" b"Content-Type: image/jpeg\r\n\r\n" + frame_jpg + b"\r\n") return StreamingResponse( generate(), media_type="multipart/x-mixed-replace; boundary=frame" )逻辑说明:generate()是个生成器,每取到一帧就 yield 一段 multipart 数据,浏览器端<img>标签直接把接口地址填到src里就能播放,不需要任何前端播放器库。camera.get_latest_frame()是摄像头后台线程持续更新的全局缓存,所以不管多少个客户端连着视频流,摄像头只会被读一次。
告警 REST 接口不需要写太多,三个就够用。
| 方法 | 路径 | 作用 |
|---|---|---|
| GET | /api/alerts | 返回历史告警列表(按时间倒序) |
| GET | /api/alerts/{id}/ack | 标记告警为“已处理” |
| GET | /api/health | 健康检查,返回服务和摄像头状态 |
3.3 WebSocket 告警推送:用队列解耦检测线程和推送线程
告警推送有两个方案:前端轮询和后端推送。轮询是每 2 秒拉一次/api/alerts,写起来简单,但告警不及时且白白耗流量。我直接说结论:必须用 WebSocket。FastAPI 里 WebSocket 的写法和 Flask 完全不同,下面这段是最简可用的实现。
# app.py 继续 from fastapi import WebSocket, WebSocketDisconnect import asyncio, queue, threading alert_queue = queue.Queue() # 检测线程写入,WebSocket 协程读取 class ConnectionManager: def __init__(self): self.active_connections = [] async def connect(self, ws: WebSocket): await ws.accept() self.active_connections.append(ws) def disconnect(self, ws: WebSocket): if ws in self.active_connections: self.active_connections.remove(ws) async def broadcast(self, message: dict): for conn in self.active_connections[:]: try: await conn.send_json(message) except Exception: # 连接已断开但还没被移除,直接剔除 self.disconnect(conn) manager = ConnectionManager() @app.websocket("/ws/alerts") async def ws_alerts(ws: WebSocket): await manager.connect(ws) try: while True: # 不 manually 收消息,只等检测线程往队列里放数据 while not alert_queue.empty(): alert = alert_queue.get_nowait() await ws.send_json(alert) await asyncio.sleep(0.1) except WebSocketDisconnect: manager.disconnect(ws) # 检测线程:detector.py 里循环跑,摔倒确认后写队列 def detection_loop(): cap = camera.start_thread() while True: frame = camera.get_latest_frame() if frame is not None: is_fall, info = detector.process(frame) if is_fall: alert_queue.put({ "type": "fall", "cam_id": 1, "timestamp": time.time(), "score": round(info["score"], 3) }) time.sleep(0.05) # 大约 20FPS 检测频率 threading.Thread(target=detection_loop, daemon=True).start()这里最常见的坑是直接把manager.broadcast()放进检测线程调,然后报错 “Loop is not running”。因为 FastAPI 的 WebSocket 收发必须运行在事件循环线程里,而检测线程是普通线程,没有事件循环。用alert_queue队列做缓冲是最朴素也最可靠的解耦方案:检测线程只负责put,WebSocket 协程只负责get,两边完全异步。
注意time.sleep(0.05)把检测频率限制在 20FPS 左右。MediaPipe 在普通笔记本上跑全分辨率逐帧推理,CPU 占用会直接打满,限制频率后能压到 40% 上下,换来的是视频流和算法互不拖垮。
4. 前端:Vue 3 + 视频流与告警面板的落地写法
4.1 前端选型:Vue 3 + Vite + Element Plus
前端部分很多同学纠结 React 还是 Vue。我的判断很直接:选 Vue 3 + Vite + Element Plus。理由是这个项目的前端核心是“视频流展示 + 告警列表 + 几个统计卡片”,不是复杂的状态管理场景。Vue 的模板语法对后端转前端的同学更友好,Element Plus 这类前端组件库把表格、弹窗、表单全部封装好了,答辩时界面能直接看。Vue 3 的<script setup>写法也比 Options API 少一半样板代码,对不常写前端的人非常友好。
如果你学校指定了若依框架,那也没关系,若依的前端本身就是 Vue 生态,我下面的 WebSocket 接收逻辑可以直接迁移进去,只需要把接口地址改成若依后端的网关路径。
4.2 实时视频流接入:MJPEG 比 WebRTC 务实得多
视频流这部分最容易过度设计。我见过有人折腾 WebRTC + Janus 网关做低延时直播,折腾了一周最后答辩时还因为浏览器安全策略弹不了窗。这里直接说结论:毕设课设用 MJPEG 流就够了,延时 200~500 毫秒,完全满足“看到老人摔倒”的需求。
前端接入 MJPEG 流的写法简单到你可能觉得不真实:
<!-- VideoPlayer.vue --> <template> <div class="video-card"> <h3>客厅摄像头</h3> <!-- 后端 /video_feed 直接作为图片源,浏览器自动刷新 --> <img :src="videoUrl" alt="实时监控" /> </div> </template> <script setup> import { ref } from 'vue' const videoUrl = ref('http://localhost:8000/video_feed') </script><img>标签加载 MJPEG 流是浏览器原生行为,不需要video标签也不需要播放器库。这里有个细节:图片请求不受浏览器 CORS 同源策略限制,所以视频流接口即使不开 CORS 也能正常显示,但 WebSocket 和 fetch 接口必须要后端处理跨域。如果你部署到服务器上用同一个域名反向代理前后端,跨域问题就不存在了,这是我推荐的生产部署方式。
4.3 告警面板:WebSocket 接收、声音提醒与自动重连
告警面板是前端的核心交互,要实时接收后端的 WebSocket 推送。下面是完整可用的逻辑,包含断线重连,这一步不能省——检测线程一直在跑,如果前端页面开着但 WebSocket 静默断开了,告警就全丢了。
<!-- AlertPanel.vue --> <script setup> import { ref, onMounted, onBeforeUnmount } from 'vue' import { ElNotification } from 'element-plus' const alerts = ref([]) let ws = null let reconnectTimer = null function connectWebSocket() { // 部署到服务器时用同一个域名,开发环境用 localhost:8000 const wsUrl = 'ws://localhost:8000/ws/alerts' ws = new WebSocket(wsUrl) ws.onopen = () => { console.log('告警通道已连接') } ws.onmessage = (event) => { const data = JSON.parse(event.data) if (data.type === 'fall') { alerts.value.unshift(data) // 最新告警放最前面 ElNotification({ title: '摔倒告警', message: `摄像头 ${data.cam_id} 检测到摔倒,请立即确认`, type: 'error', duration: 0 // 不自动关闭,等人处理 }) playAlarmSound() } } ws.onclose = () => { // 断线 3 秒后自动重连,避免页面白屏期间丢告警 reconnectTimer = setTimeout(connectWebSocket, 3000) } } function playAlarmSound() { // 用 AudioContext 直接生成提示音,不依赖外部音频文件 const ctx = new AudioContext() const osc = ctx.createOscillator() osc.frequency.value = 880 osc.connect(ctx.destination) osc.start() osc.stop(ctx.currentTime + 0.6) } onMounted(connectWebSocket) onBeforeUnmount(() => { ws && ws.close() clearTimeout(reconnectTimer) }) </script> <template> <div class="alert-list"> <h3>告警记录</h3> <el-empty v-if="alerts.length === 0" description="暂无告警" /> <el-table v-else :data="alerts" stripe> <el-table-column prop="cam_id" label="摄像头" width="100" /> <el-table-column prop="timestamp" label="时间" /> <el-table-column label="状态"> <el-tag type="danger">待处理</el-tag> </el-table-column> </el-table> </div> </template>告警数据用unshift插到列表头部而不是push,保证最新告警永远在表格第一行,这是做告警面板的基本习惯。声音提醒用 Web Audio API 现生成的振荡器音,比<audio>标签引外部文件省事,也不用担心部署时音频文件路径丢失。ElNotification的duration: 0是刻意设置的:摔倒告警必须等人手动确认,不能三秒后自己消失。
前端开发环境的跨域问题:如果你前端跑在 5173、后端跑在 8000,WebSocket 和 fetch 都会被浏览器拦截。我用 Vite 的 proxy 解决,在vite.config.js里配置代理并单独为 WebSocket 打开ws: true。部署生产环境就直接用 nginx 把/video_feed、/ws、/api都转发到 8000 端口,前端打包后的静态文件由 nginx 统一托管,这样前后端分离但对外只有一个入口。
5. 排查与避坑:骨架抖动、漏报误报和演示翻车的 5 个修复记录
5.1 现象:白天漏报、晚上误报——光线变化让骨架抖动
原因:MediaPipe 的骨架提取对光照极其敏感。白天自然光充足时关键点稳定,晚上开灯后色温变化、窗帘阴影移动、天花板灯光频闪,都会让髋部中点上下跳动,垂直速度偶尔冲破阈值,于是误报。反过来,逆光场景下骨架整个丢失,又导致漏报。
解决:第一,把单帧判据改成“连续确认”机制,单帧速度超阈值不告警,连续 5 帧超阈值才进入疑似状态;第二,对髋部 y 坐标做滑动窗口均值滤波,窗口大小取 5 帧,能滤掉多数频闪带来的抖动;第三,如果摄像头可以手动调参,把曝光时间固定住而不是用自动曝光,很多室内监控画质变差都源于自动曝光被白色墙壁骗了。
5.2 现象:摔倒后长时间不动,系统却报了“站立”
原因:老人倒在床上或沙发上时,躯干和床面贴在一起,肩部和髋部关键点被身体遮挡,extract_keypoints返回了错误的关键点坐标,或者干脆返回None。骨架丢失后判据失效,算法不知道这人到底是躺着还是站着。
解决:在检测线程里维护一个“最后有效骨架时间戳”。如果之前已经确认摔倒且进入倒地静止状态,后续帧即使骨架丢失,也保持告警状态而不是重置为正常,只有当关键点恢复且连续 30 帧满足“站立”条件时才解除告警。另外加一个兜底:当关键点置信度持续低于阈值超过 2 秒且期间有人体区域检测结果(比如背景差分显示画面中央有团静止物体),也触发告警。
5.3 现象:摄像头一多,推流延迟越来越大
原因:每个摄像头开一个后台线程轮流读帧,加一个 MJPEG 生成器,再算上同一路视频被浏览器和检测线程同时消费,CPU 和内存资源直接翻倍。到第三路摄像头时延迟明显,第四路时彻底卡顿。
解决:把“视频流展示”和“算法检测”分流。展示流直接从全局帧缓存出图,检测流单独按 15FPS 抽帧而不是逐帧推理,两者互不阻塞。USB 摄像头分辨率限制在 720p,网络摄像头在 RTSP 地址里手动指定?resolution=720p。资源实在不够就把算法检测频率再降到 10FPS,摔倒动作本身是秒级事件,10FPS 足够捕捉。
5.4 现象:前端打开了视频画面,但告警一直不弹
原因:视频流是<img>加载,不受跨域限制;但 WebSocket 和 fetch 受跨域限制。前端跑在 5173、后端跑在 8000 时,如果没配代理或后端没开 CORS,WebSocket 会默默握手失败——注意,WebSocket 握手失败不会像 fetch 那样在控制台打大红叉,只是在 Network 面板里显示 pending。
解决:浏览器按 F12 打开 Network 面板,筛选 WS 类型,看/ws/alerts这条连接的握手状态。如果一直 pending,先把后端的 CORSMiddleware 配好再测试。更省心的方法是直接用 Vite 代理,在vite.config.js里把/ws和/api都代理到http://localhost:8000,并给代理对象加ws: true,前端代码里所有请求统一走相对路径而不是写死localhost:8000。
5.5 现象:答辩现场人躺地上 5 秒没反应,算法“翻车”了
原因:实验室环境有固定灯光和背景,答辩现场灯光角度不同、墙面反光不同,骨架提取效果直接下降;加上答辩时紧张,人倒下动作可能比测试时快或慢,阈值不匹配。这是环境迁移导致的稳定复现问题,不是算法本身坏了。
解决:准备三件套。第一,答辩前一周内不要改任何参数,用同一个环境、同一段录像跑三遍回归测试,确保结果一致;第二,把阈值切到“演示模式”——倾角阈值降 10 度、确认帧数减半,牺牲一点误报率换取演示当场必报;第三,准备一段已经录制好的检测成功录像做 B 计划,万一现场摄像头驱动出问题,直接播放录像说明效果,并强调“这是摄像头实时分析保存的回放”。
6. 验收与进阶:用回放测试把效果好量化,答辩不慌
验收这件事,我建议用回放测试而不是现场摆姿势。录制三段标准视频:正常走路、弯腰捡东西、真摔倒。每段跑一遍检测,记录结果和耗时,把效果转成数字。
# run_backtest.py import cv2, time from detector import FallDetector detector = FallDetector() clips = [ ("walk.mp4", "正常行走,期望 0 告警"), ("bend.mp4", "弯腰捡物,期望 0 告警"), ("fall.mp4", "真实摔倒,期望告警") ] for clip_name, desc in clips: cap = cv2.VideoCapture(clip_name) fps = cap.get(cv2.CAP_PROP_FPS) total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) alert_frames = 0 start = time.time() while True: ret, frame = cap.read() if not ret: break if detector.process(frame): alert_frames += 1 elapsed = time.time() - start print(f"{clip_name}: 告警帧占比 {alert_frames/total_frames:.1%}," f"处理耗时 {elapsed:.2f}s,{desc}")这个脚本输出三行数字,直接贴到答辩 PPT 里。正常行走和弯腰捡物的告警帧占比接近 0%,摔倒视频的告警帧占比应该有连续一段明显的高值,同时单视频处理耗时和视频时长接近,说明实时性达标。核心验收指标建议用下面这张表自测。
| 指标 | 验收目标 |
|---|---|
| 漏报率 | 摔倒测试视频中 0 次漏报 |
| 误报率 | 日常活动视频中 0 次误报 |
| 告警延迟 | 从倒地动作开始到收到告警不超过 3 秒 |
| CPU 占用 | 单路 720p 下不超过 50%(普通笔记本) |
三个加分小技巧,按投入产出比排序。第一,告警自动截图——算法确认摔倒时,把全局帧缓存里那一帧保存成 JPEG 并随告警一起入库,前端告警列表直接显示现场图片,答辩时展示“它不仅报了警,还拍下来了”。第二,移动端适配——前端项目本身是响应式布局,Vue 项目打包后同一套代码可以直接在手机上打开,配上后端的告警推送到第三方通道,就是完整的“看护端”体验。第三,演示模式开关——内置一组保证必报的参数和一段已验证的回放视频,现场环境不可控时一键切换,这是我从几次翻车里总结出的血泪经验。
带项目的这些年,我见过太多人把精力花在堆新框架、换模型上,最后倒在“没录回放、参数改完没测、现场环境变了”这种最基础的问题上。养成习惯:每次改完参数,把同一段测试视频跑一遍,结果记下来再去做下一件事。这套系统值不值得投入,取决于你能不能在一周后、一个月后稳定复现今天的效果——能稳定复现,它就是完整可交付的工程;不能,它就只是一堆跑得通的代码。希望帮到你。
本文还有配套的精品资源,点击获取