简介:一份基于深度学习的智能坐姿检测系统完整项目,适合课程设计、期末大作业或毕业设计场景。项目通过摄像头或图像输入,利用姿态估计与分类模型实时判断人体坐姿是否规范,可扩展至学习提醒、健康监测等应用。压缩包内共15个文件,主要包括Python源码、训练数据、模型权重和音频文件:其中多个py文件分别负责数据预处理、模型训练、姿态检测主流程和简单演示界面,“.pth”为预训练模型,data目录存放标注数据,mp3可用于不良坐姿语音提醒。整体资源包仅48KB,结构紧凑,便于快速部署与二次开发。当前已有1059人学习浏览。项目经严格调试,下载后按说明配置环境即可直接运行,尤其适合需要快速搭建完整方案、深入理解深度学习落地的初学者或毕业生参考。
1. 为什么坐姿检测要选深度学习加摄像头
以前做坐姿提醒,直觉反应是上硬件:压力坐垫、毫米波雷达、带传感器的椅子,一套做下来成本几百,还要考虑供电和蓝牙连接。换个思路,用普通 USB 摄像头加深度学习模型,把画面里的人对应到“坐姿好不好”这个判断上,整套代码加数据集、训练好的权重一起跑通,成本就剩一台电脑。这个项目就是这种实现:PyTorch 训练出网络权重 net.pth,Core 目录下的 dataSet.py、process.py、pose.py 等模块完成读帧、预处理、推理、音频提醒的完整闭环,附训练集数据目录。适合三类人:做毕业设计需要完整落地链路的学生,想在公司内部做久坐提醒原型的工程师,以及研究姿态估计想找可对照基线的人。
2. 训练数据怎么组织:dataSet.py 与 process.py 的前置工作
2.1 Data/Train 的目录结构与标签方式
解压后 Data/Train 目录下是按类别划分的子文件夹,每个子文件夹名就是标签,里面放对应坐姿类别的图片。这种组织方式在图像分类项目里最常见,PyTorch 的torchvision.datasets.ImageFolder可以直接读,但项目里自己写了 dataSet.py,说明训练时还做了定制逻辑,比如按 8:2 切训练集和验证集,或者对样本做加权采样。
标签建议控制在 3 到 5 类:good、forward、lean_left、lean_right、slouch。类别太少模型学不到区分度,类别太多标注成本和误判率都会上升。我在类似项目里见过有人把类别拆成 8 类,训练集只有几百张,结果验证集准确率上不去,这种问题多半出在类别粒度而不是模型结构上。dataSet_test.py对应测试集评估入口,如果测试图片是单张拷进来的,路径写错会报 FileNotFoundError,注意看异常信息里是文件名还是目录名。
2.2 dataSet.py 里自定义 Dataset 的关键实现
dataSet.py 的核心是继承torch.utils.data.Dataset,重写__len__和__getitem__。常见做法是扫描目录,把每个样本的路径和整数标签存成列表,__getitem__里用 Pillow 或 OpenCV 读图,再套 transform。参考骨架如下:
import os import torch from torch.utils.data import Dataset from PIL import Image class PostureDataset(Dataset): def __init__(self, root, label_map, transform=None): self.samples = [] self.transform = transform for label_name in os.listdir(root): label_dir = os.path.join(root, label_name) if not os.path.isdir(label_dir): continue label = label_map[label_name] for img_name in os.listdir(label_dir): img_path = os.path.join(label_dir, img_name) self.samples.append((img_path, label)) def __len__(self): return len(self.samples) def __getitem__(self, idx): img_path, label = self.samples[idx] img = Image.open(img_path).convert("RGB") if self.transform: img = self.transform(img) return img, torch.tensor(label, dtype=torch.long)代码逻辑:__init__里把目录名映射成整数标签,__getitem__返回的是(tensor图片, tensor标签)而不是(路径, 标签),这样 DataLoader 才能直接 batching。label_map 建议写成{"good": 0, "forward": 1, ...}而不是用os.listdir的顺序自动编号,原因在于推理端(比如 simple_demo.py)也要用同一份映射,顺序一致才不会出现“训练准确率 95%,一跑摄像头全乱判”的情况。
提示:如果图片命名带中文或空格,Windows 下用
Image.open可能报 UnicodeDecodeError。统一改成img_0001.jpg这类命名能省掉很多麻烦。
2.3 process.py 预处理流水线与参数
process.py 在训练和推理两条链路里都要用到。训练链路里,它是 transform 工厂;推理链路里,它把摄像头帧转成模型输入。两处共用一份预处理逻辑,避免出现 train loss 很低、摄像头推理却一塌糊涂的经典坑。
import cv2 import torch from torchvision import transforms IMG_SIZE = 224 NORMALIZE_MEAN = [0.485, 0.456, 0.406] NORMALIZE_STD = [0.229, 0.224, 0.225] def build_train_transform(): return transforms.Compose([ transforms.Resize((IMG_SIZE, IMG_SIZE)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness=0.2, contrast=0.2), transforms.ToTensor(), transforms.Normalize(NORMALIZE_MEAN, NORMALIZE_STD), ]) def build_infer_transform(): return transforms.Compose([ transforms.Resize((IMG_SIZE, IMG_SIZE)), transforms.ToTensor(), transforms.Normalize(NORMALIZE_MEAN, NORMALIZE_STD), ]) def preprocess_frame(frame_bgr): rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB) # numpy(H,W,C) 转 PIL 再走统一 transform pil_img = Image.fromarray(rgb) tensor = build_infer_transform()(pil_img) return tensor.unsqueeze(0) # (1,3,224,224)参数说明:IMG_SIZE取 224,是因为 backbone 用 ResNet 系列时最后是 7x7 或 1x1 池化,224 是约定输入尺寸;如果你换成 EfficientNet,224 也兼容。归一化的 mean/std 用的是 ImageNet 统计值,因为加载预训练权重时模型假设输入服从这个分布。preprocess_frame里unsqueeze(0)补了 batch 维,推理时不用再手动 reshape。
2.4 数据增强怎么选和样本均衡怎么处理
坐姿检测的增强策略跟通用分类不完全一样。垂直翻转不能用,上下翻转后“人坐在椅子上”的语义直接崩掉;随机裁剪也不宜太狠,裁剪过头会把头部和肩部裁掉,模型学的特征是残肢而不是坐姿。表格里是我常用的一组配置:
| 增强手段 | 参数建议 | 理由 |
|---|---|---|
| RandomHorizontalFlip | p=0.5 | 左右倾斜天然对称,能翻倍利用样本 |
| ColorJitter | brightness=0.2, contrast=0.2 | 模拟不同光照环境 |
| RandomRotation | 5 度以内 | 摄像头角度轻微抖动 |
| RandomErasing | p=0.2 | 模拟遮挡,提升鲁棒性 |
样本不均衡在坐姿项目里很常见:good 类好拍,forward 和 slouch 类样本少。处理办法是给 DataLoader 传WeightedRandomSampler,权重按类别样本数的倒数算。我一般会把 logits 输出维度跟len(label_map)保持一致,然后在代码里打印每个 epoch 的类别准确率,而不是只看整体 acc,这样能发现“good 类 98%,forward 类 40%”的偏科问题。
3. 训练脚本与权重产出:train.py 的超参数设计和 net.pth 生成
3.1 模型选型:ResNet 还是轻量 CNN
训练阶段用 torchvision 提供的预训练 ResNet18 是比较稳妥的起步方案。坐姿图像和 ImageNet 的分布差距不算大,预训练权重能显著加快收敛,几十个 epoch 就能达到可用水平。如果部署机器没有 GPU,可以把pretrained=True改成False,或者换 MobileNetV3 这种轻量结构,代价是收敛速度变慢。素材里 Model 目录只有一个 net.pth,说明训练脚本把state_dict存成了单文件,加载时得配合模型结构定义才能用,这一点在第 4 章会展开说。
如果以后想从“分类整张图”升级到“框出人体再判断”,可以迁移到目标检测模型。参考 yolov8 训练自己数据集的流程,把标注从目录分类改成检测框格式,本质上就是把任务从图像分类换成目标分类,但训练脚本、推理链路都要重写,不建议毕设初期就上检测方案。
3.2 超参数表与典型取值
| 超参数 | 典型值 | 调整方向 |
|---|---|---|
| batch_size | 32 | 显存不够就 16 或 8;过小会导致 BN 统计不稳定 |
| learning_rate | 1e-3 | 换大模型降到 1e-4;预训练骨干通常用更小 lr |
| weight_decay | 1e-4 | 数据量小时加大到 1e-3 抑制过拟合 |
| epochs | 40~60 | 看验证集 acc 是否连续 10 轮不涨 |
| lr_scheduler | StepLR step=15 gamma=0.5 | 也可以用 CosineAnnealing |
| loss | CrossEntropyLoss | 类别不均衡时加 class_weight |
这些参数在 train.py 里一般是集中定义在文件顶部或一个 config dict 里。做课设时不要在代码里散落魔法数字,验收答辩时被问到“这个 15 是什么意思”会很狼狈,统一收口在超参数区是基本习惯。
3.3 train.py 训练骨架
下面是一段精简过的训练主循环,保留关键逻辑:
import torch import torch.nn as nn from torch.utils.data import DataLoader from torchvision import models device = torch.device("cuda" if torch.cuda.is_available() else "cpu") num_classes = 5 model = models.resnet18(pretrained=True) model.fc = nn.Linear(model.fc.in_features, num_classes) model = model.to(device) train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=2) val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=2) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=15, gamma=0.5) for epoch in range(50): model.train() running_loss = 0.0 for images, labels in train_loader: images = images.to(device) labels = labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() * images.size(0) scheduler.step() model.eval() correct = 0 total = 0 with torch.no_grad(): for images, labels in val_loader: images = images.to(device) labels = labels.to(device) outputs = model(images) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() val_acc = correct / total print(f"epoch={epoch+1} loss={running_loss/len(train_loader.dataset):.4f} val_acc={val_acc:.4f}") # 保存 state_dict,文件名对应 Model/net.pth torch.save(model.state_dict(), "Model/net.pth")逻辑说明:model.fc被替换成输出num_classes个类别的全连接层,这是迁移学习最常用的改法;optimizer.zero_grad()每组 batch 清一次梯度,loss.backward()反向传播,optimizer.step()更新权重,这个三步顺序不能乱。model.eval()和torch.no_grad()在验证时必须成对出现,否则 BN 和 Dropout 会沿用训练行为,val_acc 会虚高。保存权重用state_dict()而不是整个 model,文件更小,加载时也更灵活。
3.4 训练监控与 net.pth 保存的坑
训练日志里的 loss 下降速度和 val_acc 曲线要一起看。如果 train loss 持续下降、val_acc 徘徊不动,优先怀疑过拟合,可以加数据增强强度或增大 weight_decay;如果 val_acc 最近几个 epoch 一直在震荡,先观察到尾,不要急着调参。项目里 dataSet_test.py 对应测试集评估,训练完在这个脚本上跑一遍再发布权重,我一般会确认测试集 acc 不低于验证集 acc 的 3% 以内,差距大了说明某个子集有数据泄漏或者分布不一致。
保存 net.pth 时有个细节:如果后面想把模型换回完整 checkpoint,建议存成 dict:
torch.save({ "arch": "resnet18", "state_dict": model.state_dict(), "label_map": label_map, }, "Model/net.pth")这样加载时可以先用arch重建结构,再载入权重。如果直接保存state_dict,加载的人必须知道模型的类名和fc替换方式,否则报unexpected key(s)是必然的。素材里的 net.pth 我估计走的是第一种省事路线,所以 main.py 里module.py一定写死了 resnet18 和fc输出维度,改网络结构时记得同步改 module.py。
4. 从摄像头到声音提醒:main.py、pose.py 与 Audio 的完整链路
4.1 各模块职责边界
| 模块 | 职责 | 输入 | 输出 |
|---|---|---|---|
| module.py | 定义模型结构并加载 net.pth 权重 | 图片 tensor | 分类 logits |
| process.py | 图像预处理 | BGR 帧 | 归一化后的 tensor |
| pose.py | 做坐姿几何判断 | 模型输出的分类或关键点 | 坐姿标签 |
| view.py | 可视化与画面标注 | 原始帧 + 判断结果 | 带标注的帧 |
| main.py | 串联整个流程 | 摄像头/图片 | 无,或窗口画面 |
这个分层的好处是每一层都能单独替换。比如把 module.py 里的 ResNet18 换成 MobileNetV3,只要输入输出 shape 不变,其他模块不用动;pose.py 里如果发现角度阈值不准,也只改一个文件。
4.2 主循环数据流与代码
main.py 的主循环典型实现如下,里面关键的是用cv2.VideoCapture拿摄像头帧,经过预处理和 forward 后交给 pose.py 判读:
import cv2 import torch import time from Core.module import load_model from Core.process import preprocess_frame from Core.pose import judge_posture from Core.view import draw_result from Core.audio import play_alert model = load_model("Model/net.pth") model.eval() cap = cv2.VideoCapture(0) # 0 表示默认摄像头 bad_frames = 0 last_alert_time = 0.0 ALERT_COOLDOWN = 30 # 秒 while True: ret, frame = cap.read() if not ret: break input_tensor = preprocess_frame(frame).cuda() # 无 GPU 时去掉 .cuda() with torch.no_grad(): logits = model(input_tensor) posture = judge_posture(logits) # 返回 good / forward / lean_left / ... # 连续帧判断 + 冷却时间,避免频繁播报 if posture != "good": bad_frames += 1 else: bad_frames = 0 if bad_frames >= 15 and (time.time() - last_alert_time) > ALERT_COOLDOWN: play_alert("Audio/audio.mp3") last_alert_time = time.time() show_frame = draw_result(frame, posture) cv2.imshow("Posture Monitor", show_frame) if cv2.waitKey(1) & 0xFF == ord("q"): break cap.release() cv2.destroyAllWindows()逻辑说明:bad_frames起到时序平滑作用,防止单帧误检导致播报乱响。ALERT_COOLDOWN设定两次提醒的最小间隔,这个参数在真实办公场景中很关键,否则一整天下来的提醒次数会让人直接把程序关掉。
4.3 pose.py 的坐姿几何判断是怎么回事
如果模型输出的是类别,pose.py 直接查表映射就行;如果模型输出的是人体关键点坐标,pose.py 就要算角度。项目里单独把 pose.py 拎出来,说明它至少承担了“从模型输出到可解释坐姿标签”的转换。常见做法是用左右肩和头部三个关键点做几何判断:
import math def angle_between_points(a, b): """返回 ab 连线与水平线的夹角,单位度""" dx = b[0] - a[0] dy = b[1] - a[1] if dx == 0: return 90.0 return math.degrees(math.atan2(abs(dy), abs(dx))) def judge_posture_from_keypoints(left_shoulder, right_shoulder, nose): shoulder_angle = angle_between_points(left_shoulder, right_shoulder) # 肩线倾角大于阈值则认为左右歪斜 if shoulder_angle > 15: if left_shoulder[1] < right_shoulder[1]: return "lean_left" return "lean_right" # 鼻子在左右肩连线垂直投影上的偏移比例 shoulder_width = abs(right_shoulder[0] - left_shoulder[0]) + 1e-6 offset_ratio = abs(nose[0] - (left_shoulder[0] + right_shoulder[0]) / 2) / shoulder_width if offset_ratio > 0.25: return "forward_or_slouch" return "good"参数说明:15 度和 0.25 不是拍脑袋定的,我一般会先采集用户保持正确坐姿 10 秒,把肩线倾角和偏移比做平均,分别得到正常值,再以正常值加减两倍标准差作为阈值写入配置文件。不同身高、不同摄像头安装高度的用户,这套几何参数差异非常大,固定阈值只能保证 demo 可用,距离产品化还差一个标定环节。
4.4 Audio 音频模块的触发与防打扰
audio.mp3 的播放如果自己用 winsound 实现,只能在 Windows 跑;跨平台用 pygame 或 pyaudio 更稳。pygame 的 mixer 初始化开销不小,不要在每次提醒时都重新 init,应该在 main.py 启动时初始化一次。
import pygame _pygame_inited = False def play_alert(audio_path): global _pygame_inited if not _pygame_inited: pygame.mixer.init() _pygame_inited = True pygame.mixer.music.load(audio_path) pygame.mixer.music.play()提示:pygame 播放完立刻 load 同一文件没问题,但如果在音乐还没播完时再次调用,会直接切断前一次播放。配合 main.py 里的bad_frames >= 15和 30 秒冷却,这个情况基本不会出现。无头服务器上没有音频设备,pygame.mixer.init()会抛异常,建议用 try/except 包一层,失败时降级成打印日志。
5. simple_demo.py 快速验证与排错技巧
5.1 simple_demo.py 在部署链路里的位置
simple_demo.py 的作用是脱离摄像头和 GUI 跑推理。它的输入可以是一张图片或一段视频文件,运行完直接退出,不进入主循环,这样在做环境验证时非常方便。推荐调用方式是命令带参数,而不是改代码:
python simple_demo.py --input test.jpg python simple_demo.py --input bad_case.mp4在服务器这种无桌面环境里,main.py 的cv2.imshow会直接报错,simple_demo.py 反而是主力验证入口,我一般先跑通它再回去查摄像头问题。它也承担回归测试的角色:把之前误判的图片攒成一个目录,每次改完模型或改完阈值,全量跑一遍,数一下误判数有没有变多。
5.2 最常见的五个运行问题定位
| 现象 | 定位方向 | 处理方式 |
|---|---|---|
RuntimeError: CUDA out of memory | 视频帧分辨率过高,或 batch 设置过大 | 把帧 resize 到 640 再送模型,或者强制用 CPU |
KeyError或unexpected key加载 net.pth 报错 | 模型结构和保存权重时不一致 | 确认 module.py 里 backbone 是 resnet18,且fc输出维度跟训练时一致 |
can't open camera by index | 摄像头被其他程序占用 | 把VideoCapture(0)改成 1 或 2,先杀掉占用摄像头的软件 |
| 推理结果明显不对 | 预处理和训练时不一致 | 检查是否用了build_infer_transform(),重点看 Normalize 参数 |
| 中文路径报 UnicodeDecodeError | Windows 下 Python 默认编码问题 | 图片和数据集路径全部改英文,这是最省事的一种 |
5.3 把双阈值和告警渠道改成可配置
坐姿检测这类项目,交付后用户一定会有“提醒太频繁”“我在喝水也算前倾”这类反馈。改代码不是解决办法,把阈值和提醒间隔全部搬进一个 JSON 配置文件,才算真正收尾。下面是可以直接用的结构:
import json with open("config.json", "r", encoding="utf-8") as f: cfg = json.load(f) THRESHOLD_ANGLE = cfg["pose"]["shoulder_angle_deg"] THRESHOLD_OFFSET = cfg["pose"]["head_offset_ratio"] ALERT_COOLDOWN = cfg["alert"]["cooldown_sec"]对应 config.json 里是{"pose": {"shoulder_angle_deg": 15, "head_offset_ratio": 0.25}, "alert": {"cooldown_sec": 30}}。改动阈值后重启进程即可,不用动一行 Python 代码。在此基础上再做一步:play_alert里除了播 audio.mp3,同时调用一个 webhook 通知函数,把“什么时候检测到异常坐姿”推送到企业微信或钉钉机器人,就具备了给多台办公电脑做统一久坐监控的雏形。
本文还有配套的精品资源,点击获取