我最初拿到这个题目时,脑子里第一反应是:教育场景里的行为分析,听着像安防监控的副产品,实际上要落地的坑比普通目标检测多得多。学生行为不像交通流量那样尺度固定、形态单一,课堂上有遮挡、有密集座位排列、有大幅度姿态变化,还有隐私合规要求,这决定了你不能直接拿一个预训练权重就跑生产。我做的这套系统基于YOLOv11,配合Python和Django搭Web服务,覆盖图片检测、视频文件检测、摄像头实时检测三种输入方式,配套完整技术文档,算是把课堂行为识别从模型训练到Web部署打通了。这篇总结就把我的设计思路、训练细节、集成方案和踩坑记录完整写出来,希望能帮到正在做同类项目的开发者。
这个项目本质上解决的是“课堂中自动识别学生外观行为”的问题,典型行为类别包括:举手、阅读、写字、趴桌、玩手机、起立、行走、交头接耳等。传统做法是人工巡课或者看监控回放,效率低且无法实时干预。用深度学习模型做自动识别,再通过Web系统提供检测服务,是当前智慧校园落地比较成熟的技术路径。适合的读者包括:做教育信息化项目的开发者、高校里做计算机视觉研究的同学、以及想将YOLO模型集成到Web应用中的后端工程师。读完你可以得到一套可以复用的架构参考,以及我在训练和部署过程中总结的实操参数。
1. 系统整体设计与技术选型思路
1.1 为什么选YOLOv11而不是其他目标检测模型
先说明一点,我接手项目时也对比过YOLOv5、YOLOv8和YOLOv11。YOLOv5社区生态好,资料多,但模型结构相对老,对密集小目标的特征提取能力在课堂场景下有点吃力。YOLOv8引入了C2f结构和无锚框检测头,训练收敛更快,但v8在部署时的灵活性一般,特别是需要自定义检测头做行为分类时,代码侵入性较大。YOLOv11在v8基础上进一步改进了Backbone的特征融合方式,增加了一些可训练的注意力模块,同时保持了单阶段检测的推理速度,在实际测试中,对课堂里遮挡严重的“趴桌”行为,平均精度比v8高2到3个百分点,推理速度在同样GPU上几乎持平。
图像、视频、摄像头实时检测三种模式要同时支持,这要求模型不能太大,TensorRT加速后需要稳定跑在30FPS以上,否则实时监控就是空话。YOLOv11提供了n、s、m、l、x五个尺寸,我最后选用的是YOLOv11n和YOLOv11s两个版本:边缘设备或低配服务器用n,测试环境用s。如果你只做离线图片分析,可以用m或l提升精度,但实时流建议别超过s,否则延迟很难看。
1.2 Web框架选型:为什么用Django
模型是核心,但用户不可能拿着命令行去跑检测,必须有一个Web界面。选Django而不是Flask或FastAPI,主要三个原因:第一,Django自带Admin后台和ORM,检测记录、学生信息、课程表这些结构化数据的管理成本极低;第二,Django的项目结构适合这种“管理后台+检测展示”的业务场景,模板系统能快速做出上传页面和结果展示页面;第三,Django的中间件和CSRF防护相对完善,做教育类系统要对外演示时,安全性观感更好。
当然Django也有短板,它的WSGI模型是同步阻塞的,直接放模型推理会导致并发能力很差。我的方案是:Django只负责接收请求、管理任务、展示结果,实际推理交给独立的模型服务进程,通过Redis任务队列异步通信。这个架构在后面第3章详细讲。
1.3 系统总架构:模型、后端、前端与数据流
整个系统按模块拆成四层:
| 层级 | 技术组件 | 职责 |
|---|---|---|
| 表现层 | Django模板 + JavaScript + Video.js | 上传图片/视频、展示检测结果、实时画面监控 |
| 应用层 | Django View + Django REST Framework | 请求路由、权限控制、任务调度、结果查询 |
| 模型服务层 | YOLOv11 + PyTorch + ONNX Runtime / TensorRT | 图像预处理、推理、后处理、行为标签生成 |
| 数据层 | PostgreSQL + Redis | 检测记录持久化、任务队列、缓存热数据 |
数据流分两条链路。离线检测链路:用户在Web上传图片或视频,Django生成任务ID,把文件路径写入Redis队列,模型服务进程从队列消费,检测完成后把结果写入数据库,前端轮询任务状态并展示。实时检测链路:摄像头RTSP流推送到系统,模型服务按帧抽帧检测,检测结果通过WebSocket推给浏览器,同时按时间窗口聚合行为统计。
这种分层最大的好处是模型推理和Web应用解耦,模型升级、换权重、加行为类别都不需要重启Django服务。我一开始图省事把模型直接加载在Django进程里,并发一上来CPU直接打满,后来拆开才稳定下来。
2. 数据准备与模型训练实操
2.1 行为类别定义与标注策略
数据决定模型上限。我定义的行为类别经过了三轮调整,最初模仿通用行为识别论文列了12类,比如“举手”“低头”“抬头”“写字”“阅读”“站立”“走动”“玩手机”“趴桌子”“交流”“遮挡”“其他”,训练后发现“低头”和“写字”高度重叠,标注人员也很难判断,模型学得一团糟。最后压缩成7类:举手、阅读、写字、玩手机、趴桌、站立、行走。这7类在课堂场景中外观可区分度足够高,也方便后续做教学干预。
标注策略上重点提一点:不要只看在线公开数据集。课堂场景的数据非常特殊,摄像头通常安装在教室前方或角落,学生是后排排列的,天然存在大量小目标和遮挡。我采集了三个教室、不同时段、不同光线条件下的视频,按每5秒抽一帧标注,总计2.1万张图像。标注工具用LabelImg,输出YOLO格式的txt文件。每张图的标注原则是:目标被遮挡超过50%就不标,避免引入噪声;同一行为只标注可见人体边界框,不标头部和手部细节。这样做是为了让模型专注学习“人+行为”的整体模式,而不是被局部干扰。
2.2 YOLOv11训练的初始化与超参数配置
YOLOv11的官方仓库提供了非常完整的训练入口,我用的是Ultralytics封装。关键超参数如下:
# dataset.yaml path: ./datasets/classroom train: images/train val: images/val names: 0: raise_hand 1: reading 2: writing 3: phone 4: lying 5: standing 6: walking训练命令:
yolo detect train data=classroom.yaml model=yolo11s.pt epochs=200 imgsz=640 batch=16 device=0 lr0=0.01 lrf=0.01 cos_lr=True选yolo11s.pt做预训练权重,而不是从零训练,因为COCO预训练权重已经学到了通用物体特征,尤其是人体的轮廓、姿态、遮挡关系,迁移到课堂行为场景可以大幅缩短收敛时间。我从实际训练曲线看,如果不加载预训练,前50轮loss下降很慢,加载预训练后第20轮就已经能出可用的结果。
训练参数里我重点改了三个值:imgsz用640而不是1280,课堂小目标多,按理说大分辨率更好,但实时性和显存占用会指数上升,640配合yolo11s在8G显存上batch_size调到16已经是极限;cos_lr打开,让学习率周期下降,行为分类这种细粒度任务比通用检测需要更平滑的收敛;epochs设200,配合早停机制,我大概在145轮左右就触发了早停,后续没有再提升。
2.3 数据增强与过拟合应对
课堂数据很容易过拟合,因为同一个教室的背景、座位排列、光线颜色高度相似,模型往往学到的是“背景+固定位置”,而不是行为本身。我做了两类处理:
Mosaic增强:Ultralytics默认开启Mosaic和MixUp,我把Mosaic概率设为1.0,MixUp设为0.2。Mosaic把四张图拼成一张,模型被迫在不同背景下学习目标特征,削弱对单一场景的记忆。
随机擦除和HSV扰动:对颜色空间做增强,模仿不同时间教室灯光变化;随机擦除部分区域能提高被遮挡目标的鲁棒性,但擦除比例不能太高,否则趴桌和低头这种关联细节都被擦没了,我设了erase_scale=0.2,测试下来对验证集精度提升约1.5个点。
过拟合的判断指标不能只看mAP。我每周跑一次真实视频样例,观察模型在未见过的教室里的动态表现,发现如果只在原场景测试,mAP可能连续几次都在80以上,但换了新教室后掉到60,这就是典型的过拟合。后来加入了同一硬件环境不同座位模式的跨场景数据,情况才明显缓解。
2.4 模型评估与剪枝
训练完需要用独立测试集评估。我按视频来源分配训练集、验证集和测试集,保证同一摄像头同一时段的视频不会同时出现在训练和测试中,这样评估结果才真实反映泛化能力。最终yolo11s在测试集上的mAP50约为0.87,mAP50-95约为0.65,其中“趴桌”精度最低,约0.79,“站立”和“行走”精度最高,接近0.93。精度低的类别往往是样本数量少或形态受限,我在数据准备时额外补充了趴桌在不同位置、不同光照下的样本,比其他类别多了30%。
部署到生产环境时,我对模型做了剪枝和INT8量化。剪枝使用结构化稀疏,去掉通道贡献低于阈值的卷积核,模型大小从22MB降到14MB;然后转ONNX再用TensorRT进行INT8量化,需要校准数据集,我从验证集随机抽500张图做校准。量化后mAP掉了1.8个百分点,但推理速度从12ms提升到5ms,对实时摄像头检测来说完全值得。
3. Django集成与三种检测模式实现
3.1 Django项目结构与模型服务进程解耦
Django工程目录我按功能模块划分:
classroom_system/ ├── manage.py ├── config/ │ ├── settings.py │ └── urls.py └── apps/ ├── accounts/ # 用户登录与权限 ├── detection/ # 检测任务、上传、结果展示 ├── camera/ # 摄像头管理与实时流 └── reports/ # 行为统计报表模型服务单独放一个包,不依赖Django的settings,也不使用ORM,只通过Redis交互。这样模型进程可以在独立GPU机器上运行,Django纯CPU也能撑起高并发访问。
模型服务代码核心结构:
# model_worker.py import torch import cv2 import numpy as np from ultralytics import YOLO import redis import json import time class YOLOWorker: def __init__(self, weights_path, device="cuda:0"): self.model = YOLO(weights_path) self.model.to(device) self.model.eval() self.redis = redis.Redis(host="localhost", port=6379, db=0) def preprocess_image(self, img): # 统一缩放到模型输入尺寸,保留比例,不足部分填充灰色 h, w = img.shape[:2] scale = min(640 / h, 640 / w) new_h, new_w = int(h * scale), int(w * scale) resized = cv2.resize(img, (new_w, new_h)) canvas = np.full((640, 640, 3), 114, dtype=np.uint8) canvas[:new_h, :new_w] = resized return canvas def predict_image(self, img): results = self.model(img, conf=0.45, iou=0.5, imgsz=640, verbose=False) boxes = results[0].boxes detections = [] if boxes is not None: xyxy = boxes.xyxy.cpu().numpy() conf = boxes.conf.cpu().numpy() cls = boxes.cls.cpu().numpy().astype(int) for i in range(len(cls)): detections.append({ "bbox": xyxy[i].tolist(), "confidence": round(float(conf[i]), 4), "class_id": int(cls[i]), "class_name": results[0].names[int(cls[i])] }) return detections def loop(self): while True: # 从Redis队列取任务 task = self.redis.blpop("detect_tasks", timeout=0) if not task: continue task_data = json.loads(task[1]) task_type = task_data.get("type") start = time.time() if task_type == "image": img = cv2.imread(task_data["path"]) detections = self.predict_image(img) self.redis.set(f"result:{task_data['task_id']}", json.dumps(detections)) elif task_type == "video": self.process_video(task_data) self.redis.lpush("task_logs", json.dumps({ "task_id": task_data["task_id"], "inference_time": round(time.time() - start, 2) }))这个Worker用blpop阻塞读取任务队列,避免轮询CPU空转。任务执行完把结果写入Redis带过期时间的key,前端查询到后落库存为历史记录。
3.2 图片上传检测接口实现
Django端上传图片接口接收multipart文件,保存到MEDIA_ROOT,生成task_id,把文件路径push到Redis,然后立即返回task_id。前端轮询任务状态。这里关键点在于不要让Django同步等待模型结果,否则一个检测任务耗时2秒,Django进程就被占用,其他请求全部排队。
视图代码:
# views.py import uuid import redis from django.http import JsonResponse from django.views.decorators.csrf import csrf_exempt from .models import DetectTask r = redis.Redis(host="localhost", port=6379, db=0) @csrf_exempt def upload_image(request): if request.method != "POST": return JsonResponse({"error": "POST only"}, status=405) f = request.FILES.get("image") if not f: return JsonResponse({"error": "missing image"}, status=400) task_id = str(uuid.uuid4()) ext = f.name.split(".")[-1].lower() save_path = f"uploads/{task_id}.{ext}" with open(save_path, "wb") as fp: for chunk in f.chunks(): fp.write(chunk) DetectTask.objects.create(task_id=task_id, input_type="image", status="pending") r.lpush("detect_tasks", json.dumps({ "task_id": task_id, "type": "image", "path": save_path })) return JsonResponse({"task_id": task_id, "status": "pending"}) def query_result(request, task_id): task = DetectTask.objects.get(task_id=task_id) if task.status == "pending": result = r.get(f"result:{task_id}") if result: detections = json.loads(result) task.status = "done" task.result_json = detections task.save() r.delete(f"result:{task_id}") else: return JsonResponse({"status": "pending"}) return JsonResponse({"status": task.status, "detections": task.result_json})实际测试中,单张640x640图片从上传到返回结果大约1.2秒,其中模型推理仅占0.4秒,其余时间花在文件IO和小图渲染上。如果部署环境用SSD,延迟可以压缩到0.9秒以内。
3.3 视频文件检测:抽帧策略与结果合成
视频检测不能逐帧推理,否则一个10分钟视频会跑出1.5万次推理,耗时过长且结果冗余。我采用“关键帧抽帧+目标跟踪”的思路。每隔5帧抽一次检测,相邻检测结果用IoU匹配,如果同一目标在连续帧中被检测到且类别一致,就合并为一段行为记录,避免同一行为被重复框出几十次。具体来说:
- 视频按30FPS读取,每5帧取1帧执行YOLO推理。
- 检测到目标后,用简单的IoU匹配关联到上一关键帧的目标ID。
- 如果目标在超过1秒的时间内保持同一类别,则记录一条有效行为并生成该行为起始和结束时间。
- 最终在视频上叠加绘制不同颜色框和标签,抽出每个行为类别的典型帧生成预览图。
这个策略比纯逐帧检测节省90%计算量,同时保留了行为时长信息。我在代码里直接用OpenCV的VideoCapture读取视频:
def process_video(task_data): cap = cv2.VideoCapture(task_data["path"]) fps = cap.get(cv2.CAP_PROP_FPS) if fps == 0: fps = 30 sample_gap = 5 frame_count = 0 detections_buffer = [] while True: ret, frame = cap.read() if not ret: break if frame_count % sample_gap == 0: dets = worker.predict_image(frame) detections_buffer.append({ "frame": frame_count, "time": frame_count / fps, "detections": dets }) frame_count += 1 cap.release() # 汇总结果,按目标关联生成行为时长 behaviors = merge_detections(detections_buffer, fps, gap_threshold=15) # 保存结果这里要注意frame_count可能非常大,不能用普通列表无限存,我在merge之前用流式聚合,只在内存中保留最近两个关键帧的目标框,其余直接写入临时数据库。否则一部1080P长视频能把内存吃满。
3.4 摄像头实时检测:RTSP拉流与WebSocket推送
摄像头实时检测是要求最高的一块,涉及网络流、多线程和前端播放。我的摄像头模块支持两类来源:USB摄像头(教室一体机和普通摄像头)和RTSP网络摄像头。USB摄像头直接用opencv打开设备号或V4L2通道,RTSP地址格式如rtsp://user:password@ip:554/stream1。
实时检测的处理线程模型:
- 主线程:Django后端启动一个CameraManager,为每个摄像头创建一个CaptureThread。CaptureThread循环读取帧,放入一个长度有限的队列(比如5帧),避免帧堆积导致延迟无限变大。
- 推理线程:从队列取最新帧(丢弃旧帧),执行YOLO推理,把叠加了标注框的帧编码成JPEG,通过WebSocket推送给前端。
- WebSocket用Django Channels实现。在前端页面上,用
<img>标签不断更新base64图片,这种方式比播放video标签加canvas覆盖标注框简单得多,兼容性也好。实测在局域网内,720P分辨率下从摄像头采集到画面推送到浏览器约350毫秒,1080P在此基础上增加150毫秒,基本满足实时监看需求。
推流代码核心:
# consumers.py import asyncio import base64 import json from channels.generic.websocket import AsyncWebsocketConsumer class CameraConsumer(AsyncWebsocketConsumer): async def connect(self): self.camera_id = self.scope["url_route"]["kwargs"]["camera_id"] await self.accept() # 注册到CameraManager的订阅列表 CameraManager.add_subscriber(self.camera_id, self) async def disconnect(self, code): CameraManager.remove_subscriber(self.camera_id, self) async def receive(self, text_data): data = json.loads(text_data) if data.get("action") == "stop": await self.close() async def send_frame(self, frame_bytes): b64 = base64.b64encode(frame_bytes).decode("ascii") await self.send(text_data=json.dumps({"frame": b64}))CameraManager每隔200ms向所有订阅者推送一帧,如果某个客户端网络慢,就直接跳过该帧,保证延迟优先而不是流畅优先。监控场景里,管理者宁可看到轻微掉帧,也不能接受画面延迟超过1秒,因为实时干预需要低延迟。
前端接收推送每帧刷新img的src,配合行数统计面板。数据格式我额外推送了每个行为类别的人数,浏览器端用简单的表格渲染当前课堂行为统计,比如“举手3人、阅读12人、写写18人”,这类实时统计对老师决策很有用。
4. 常见问题与排查技巧实录
4.1 YOLOv11推理速度不稳定,时快时慢
表现:摄像头检测画面时,偶尔掉到个位数FPS,GPU利用率不高,但CPU持续高负载。
原因排查:训练默认开启的很多预处理线程在我们自定义Worker环境中没有释放,OpenCV的imread和BGR转RGB都占CPU。后来发现GIL也是罪魁祸首,虽然PyTorch底层释放了GIL,但Python业务代码之间的锁竞争仍然存在。
解决方案:把预处理、推理、后处理的每个阶段拆分成独立的Popen子进程或使用multiprocessing进程池,而不是线程;确保模型加载在主进程,推理时只传张量数据。我最终用torch.multiprocessing的fork方式启动Worker,推理进程独立,完全绕开GIL。调整后单线程吞吐从18FPS提升到27FPS。
4.2 小目标和密集人群漏检
课堂后排学生目标尺寸可能只有60x30像素,即便原图是1080P,下采样到640后后排学生宽度只有20像素左右,模型几乎无法识别。我在训练时把分辨率提高到960x960,同时开启SAHI(切片辅助推理),对于图片和视频检测模式,把大图切成多个512x512块,每块独立检测再合并结果。SAHI对小目标提升显著,后排学生召回率从0.65提升到0.82。但切片推理耗时翻了三倍,在实时摄像头链路上不能直接用,所以实时摄像头我保持640分辨率,只对离线图片和视频使用SAHI。
4.3 行为误报:把“站立回答问题”识别成“行走”
这是因为数据标注时没有区分定点站立和走动的边界。模型对运动目标会综合位置变化判断,但单帧检测本质上没时序信息,只能靠姿态判断。我增加了后处理规则:如果同一目标连续5秒的位置坐标变化小于一个阈值(例如身体中心移动距离小于50像素),即使模型输出“行走”,也会被修正为“站立”。这种规则补丁在纯目标检测模型上做时序平滑,成本极低但效果明显。
4.4 Django上报错“Worker timed out”或视频上传超时
很多部署环境用默认的同步WSGI服务器(比如Gunicorn单个worker),处理视频上传和长轮询查询容易超过30秒超时。我的做法是:Gunicorn配置成3个worker,timeout=120,同时把大文件上传走Chunked传输或改用nginx上传模块。另外,不要让Django直接返回检测好的视频文件,正确做法是任务完成后生成结果视频路径,前端单独通过静态文件服务下载。
4.5 模型结果写入数据库时的类型问题
YOLO输出bbox是tensor,直接转json会报TypeError。我统一在Worker端将所有的numpy类型转成Python原生float/int,并用round限定小数位。还有一个坑是class_id和class_name顺序,如果本地训练时names顺序和部署时加载的权重不一致,会出现标签错乱。我建议把names写死在权重文件对应的yaml中,并在Worker初始化时打印一次类别映射,人工核对一遍,处理线上事故时能省半小时。
4.6 技术文档编写的实际经验
这个项目要求有技术文档,我踩过最深的坑是文档和代码不匹配。模型参数、类别名、接口地址在迭代中改了好几次,文档没有同步,导致某公司按旧文档集成时调不通接口。后来我总结了一套文档维护方法:
- 接口文档用OpenAPI格式,从Django REST Framework自动生成,保证精确匹配代码。
- 模型训练文档单独记录超参数、训练集统计数据和数据增强策略,这部分作为“实验报告”,不放入使用手册。
- 部署文档必须包含从裸机开始的完整命令,包括环境变量、模型权重位置、Redis/PostgreSQL初始化语句。
- 文档版本跟随代码仓库的标签,每次发布时同步更新,不允许“先改代码后补文档”。
技术文档不仅给使用者看,也给自己留档。三个月后想重新训练一个新行为类别,我当时记录的数据统计和分析结论仍然可以直接参考,这点是很多开发团队忽略的。
4.7 常见问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| GPU显存不足 | batch_size过大或imgsz过高 | 降低batch到8,imgsz用640 |
| 图片检测结果为空 | conf阈值太高 | 将conf从0.5降到0.35~0.45 |
| 视频检测卡顿 | 逐帧推理太慢 | 每5帧抽1帧,或改用RTSP硬解码 |
| Django接口500 | 模型服务未启动或Redis连不上 | 检查celery worker和redis-cli ping |
| 摄像头画面黑屏 | RTSP鉴权失败 | 确认用户名密码,使用VLC验证流地址 |
| 某类行为完全检测不到 | 训练样本不平衡 | 为该类别补充数据并重训,或开启数据增强 |
| WebSocket连接频繁断开 | nginx代理缓冲关闭 | 配置proxy_buffering off并设置正确的Upgrade头 |
| 模型推理和Web应用进程互相抢占资源 | 同机部署无隔离 | 设置docker资源限制,或用K8s调度分开部署 |
5. 项目扩展建议与个人经验
系统做到这个程度,其实已经覆盖了课堂巡课和远程督导的基本需求。在此基础上还能继续扩展的方向很多:接入教室多媒体一体机的自动开关机策略,基于行为统计结果做教学干预提醒;利用OpenPose或姿态估计模型配合YOLO行为框做二次姿态校验,减少趴桌和埋头阅读的混淆;把行为序列按时序模型建模,预测学生注意力曲线的拐点,这些都属于“行为识别+”的高阶玩法。
我个人在实际操作中最大的体会是,深度学习项目里,模型训练只占30%工作量,剩下的70%都在数据清洗、服务封装和规避边缘case。YOLOv11本身足够好用,Ultralytics的封装对这些细节的托底也相当友好,但你千万不要把开源仓库的demo一键运行当成生产系统。真的要部署到学校机房,你必须认真对待并发、延迟、模型更新、数据隐私这几个问题。
最后再分享一个小技巧:调试实时摄像头时,先用VLC或者ffplay确认RTSP流能够直接播放。很多摄像头在浏览器取流时会因为编解码格式不通导致黑屏,不是代码问题,是H.265编码不支持。遇到这类情况,统一转成H.264或者用后端硬解码之后再送模型。另一个技巧是,在Django项目里加一个简单的健康检查接口,返回当前模型服务的实时状态和最近1分钟平均推理耗时。这个接口看起来不起眼,但在演示活动现场能帮你提前发现GPU掉线、模型没有加载完毕这些意外状况,避免在校长和老师面前翻车。
如果这套系统的思路对你有帮助,建议先拿一个教室的真实录制视频,把数据集跑通,再考虑整合Django。不要一上来就做实时摄像头,先把离线视频检测的准确率和稳定性做到能接受的程度,实时部分只是把离线流程缩短到一个推理周期内而已。数据、模型、服务,一步一步来,这个项目没有想象中那么难,但也没有捷径可走。