☰
基于OpenCV和Python的教室内座位自动识别与标注项目实战
2026/10/26 16:29:37 网站建设 项目流程

简介:这是一份基于OpenCV与Python实现的教室座位自动识别与标注项目源码包,适合软件工程、计科、人工智能等专业的在校学生用于毕业设计、课程设计或项目演示,也可作为开发者学习计算机视觉与目标检测的进阶素材。整包共26个文件,包含12个Python源码文件、9张可视化效果图、1份Word使用手册、1个说明文本以及3个分卷压缩数据包,整体约74.53MB;源码覆盖数据预处理、特征提取、分类器训练、HOG可视化、非极大值抑制等完整检测流程。资源目前已有162人学习下载,代码经本地编译运行通过,评审得分95分以上。借助配套文档、示例图片与分模块的代码组织,使用者可快速理解教室座位检测的完整实现思路,并在此基础上改造或扩展功能,直接用于课设、毕设或初期立项演示。

1. 一个教室座位识别项目,为什么能从“课设作业”变成“生产方案”

期末监考、教务查课、自习室管理,都会遇到同一个问题:想知道某个教室“哪个座位有人、哪个座位空着”,而不是只知道“来了多少人”。这个基于 OpenCV+python 的教室内座位自动识别与标注项目,本质就是把两件事串起来——先用目标检测找到画面里的“人”,再用 OpenCV 把“人的位置”和“预先标定的座位区域”做匹配,最后在画面上把每个座位框出来并标注成 occupied / free。它解决的不是单纯的“数人头”,而是“带座位编号的人头统计”。

这个项目最容易被误解的地方在于:大家以为难点在“识别”,真正反复返工的地方却在“标注”和“映射”。模型输出的是朴素的矩形框,教室里的座位是透视变形的网格,两个坐标系不对齐,再准的检测模型也落不了地。适合做这件事的人,是手里有固定机位教室图像、想从零跑通一套“检测+标定+标注”管线的开发者,以及准备拿计算机视觉做课程设计、想把完整工程链讲清楚的学生。下文从选型一路讲到参数和坑,照着能复现。

2. 为什么“传统 OpenCV 方法”做不动,选型要先想清楚

2.1 教室场景的三个难点:遮挡、姿态、光照

先看一个反直觉的结论:直接用 OpenCV 的肤色检测、背景差分、HOG 行人检测来做这种题目,十有八九会翻车。教室场景里人不是“站着排队”的行人姿态,而是坐着、趴在桌上、举手、低头写字,HOG 专门针对“直立行人”训练,对坐姿目标非常不友好;肤色检测会被投影仪光线和桌面反光干扰;背景差分只要有人调窗帘、开投影,整个前景面具就废了。

真正的难点有三个。第一是遮挡:前排同学挡住后排同学的下半身,检测框互相重叠;第二是姿态变化:一个座位上的学生可以从正脸转到侧脸,再趴在桌上,外观差异大得离谱;第三是光照:教室的灯一排一排独立控制,窗边和门口亮度差好几档,同一个模型在不同物理教室之间的效果差异很大。这些难点决定了“纯传统视觉”只能做人数级别的粗统计,做不了“第几排第几座有人”。

2.2 主流方案对比:检测模型 + OpenCV 后处理的组合

现在做教室座位识别,业界的惯例是把任务拆成两段式,而不是一口气端到端。第一段用目标检测模型识别画面里的“人”,第二段用 OpenCV 做“结果到座位坐标”的映射与可视化。下面这个表是我在选型时常用的对比:

方案能识别到人能对应到具体座位抗遮挡开发工作量适用场景
纯 OpenCV 传统视觉(背景差分/肤色)勉强不能弱小实验室演示
端到端密集预测(直接回归每个座位的占用状态)依赖训练数据量能中等很大,需要逐座位打标签固定教室长期使用
YOLO 检测人 + OpenCV 做座位匹配能能良好中等多教室复用、课程设计、中小型部署

我一般会选第三种。理由很实际:检测“人”的训练数据在公开权重里大量存在,你不需要从零训练;而“某个座位是否被占用”这件事,透过检测框和座位区域的交并比就能推出来。相比给每个座位单独打标签做分类,这种做法的迁移成本低——换个教室,重新标一下座位区域就行,模型不用重训。

2.3 环境准备:OpenCV 与 Python 的最小安装组合

无论你手头的源码长什么样,环境装不对,第一步就跑不动。常见的坑是 Python 版本太新导致 OpenCV 预编译包还没有对应轮子,以及把 OpenCV 装进了系统 Python 而不是项目虚拟环境。下面是我习惯的最小安装命令:

python -m venv venv source venv/bin/activate # Windows 下是 venv\Scripts\activate python -m pip install --upgrade pip pip install opencv-python==4.8.1.78 pip install numpy==1.26.0 pip install ultralytics==8.0.200 # 用于训练和导出 ONNX

参数说明:这里故意固定了 opencv-python 的版本,因为 4.8.x 系列的 dnn 模块对 ONNX 的兼容比较稳当,太新的版本偶发算子兼容问题;ultralytics 8.x 是 YOLOv8 的训练框架,同时也兼容加载 YOLOv5 的官方权重。如果你只需要推理,不重新训练,那么 ultralytics 这条依赖可以去掉,推理部分用 OpenCV 的 dnn 模块就够了——这正好对应标题里“基于 OpenCV + Python”的定位:训练是辅助,OpenCV 才是主角。

3. 把数据准备好:标注工具、VOC 转 YOLO 与可复用脚本

3.1 数据采集与标注工具的选型

“优秀项目”里常见的标注源码,很多直接用了 labelImg 生成的 Pascal VOC 格式。我不建议你打开一个新项目后急着写模型代码,先把数据管线跑通有更大价值。采集视频时,让摄像头固定在教室后方墙角,角度稍微俯视,连续录 20 到 30 分钟,然后按每 5 秒抽一帧的方式取图。注意录到以下难样本:后排逆光、学生穿与黑板同色系衣服、前排人起身挡住后排、教室开一半灯。

标注工具方面,网上能搜到很多标注工具,但 labelImg 仍然是入门性价比最高的选择:单机免配置,能用快捷键快速画框,导出格式直接是 Pascal VOC XML。你不一定非要组织人海战术标几千张,300 到 500 张有效图对一个“只分人/不分人”的单类检测任务已经足够起步。标注时只标“人”,不标桌椅。这样你的模型学习的是“完整的、被遮挡的、各种姿态的人”,座位属于后台映射的事。

3.2 用 labelImg 标注时,三个关键设置别改错

图上只推标签“person”还不够,有三个细节直接影响后面脚本的解析。首先,标注时保持“从左上角拉到右下角”的矩形习惯,labelImg 记录的是 xmin、ymin、xmax、ymax,坐标系原点在图片左上角;其次,如果一个学生被严重遮挡,导致可见部分不到整个人体的三分之一,我的建议是直接跳过不标,不要画一个半人半物的框,这种框会把后排误检带偏;最后,保存格式选 PascalVOC,不是 YOLO,因为后面用脚本转换比在工具里手切格式更方便。

xml里有一个字段是<path>,指向本地图片的绝对路径。多人协作时,这台机器的路径到那台机器上就不存在了,脚本要做的是忽略这个字段,只按文件名去拼接相对路径,否则一换机器就报文件找不到。

3.3 写脚本把 VOC 批量转成 YOLO 训练格式

YOLO 系列训练需要的是归一化的 txt 文件,每行是class_id x_center y_center width height。转换脚本不难,却是我看过的源码包里最容易写错的部分。下面这段脚本可以直接复用:

import os import xml.etree.ElementTree as ET def convert_voc_to_yolo(xml_path, target_dir, class_name="person"): os.makedirs(target_dir, exist_ok=True) for xml_file in os.listdir(xml_path): if not xml_file.endswith(".xml"): continue tree = ET.parse(os.path.join(xml_path, xml_file)) root = tree.getroot() img_w = int(root.find("size/width").text) img_h = int(root.find("size/height").text) lines = [] for obj in root.findall("object"): name = obj.find("name").text if name != class_name: continue box = obj.find("bndbox") xmin = max(int(box.find("xmin").text), 0) ymin = max(int(box.find("ymin").text), 0) xmax = min(int(box.find("xmax").text), img_w) ymax = min(int(box.find("ymax").text), img_h) if xmax <= xmin or ymax <= ymin: continue x_center = (xmin + xmax) / 2.0 / img_w y_center = (ymin + ymax) / 2.0 / img_h w = (xmax - xmin) / img_w h = (ymax - ymin) / img_h lines.append(f"0 {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}") yolo_name = os.path.splitext(xml_file)[0] + ".txt" with open(os.path.join(target_dir, yolo_name), "w") as f: f.write("\n".join(lines)) if __name__ == "__main__": convert_voc_to_yolo("annotations_xml", "labels_yolo")

逻辑说明:脚本先读取 XML 里的图片尺寸,然后遍历每一个 object,把左上角和右下角坐标换算成中心点加宽高的归一化格式。注意里面做了两处防御处理:一是把 xmin、ymin 强制截断到 0 以上、xmax、ymax 截断到图片尺寸以内,避免标注框越界导致训练时 loss 突变;二是遇到宽或高为零的非法框直接跳过。

参数说明:class_name="person"取决于你在 labelImg 里设的类别名,如果你标成student,这里就要改;target_dir生成的结果与图片文件同名不同后缀。转换完成后,建议随机抽三个 txt 文件,打开看看坐标值是否都在 0 到 1 之间,不在这个范围的一定是脚本读错了尺寸字段。

3.4 按视频片段划分数据集,别用随机划分

划分训练集与验证集时,最容易犯的错是“全局随机抽样”。视频抽帧得到的图片之间高度相关,同一秒内连续两帧的人几乎一样,如果一张进了训练集、一张进了验证集,验证指标会虚高很多,换到新教室立刻现原形。我一般按时间序列切分:把整个视频分成三段,前两段做训练,最后一段做验证。如果采集了多个教室的数据,那么按“教室”划分——即一个教室的视频全部进训练集,另一个教室的全部进验证集,这样测出来的泛化能力才是真实的。划分完,给训练脚本加一个固定随机种子,保证每次迭代重跑的结果一致。

4. 训练一个只认“人”的模型:命令、参数与 ONNX 导出

4.1 用公开预训练权重起手,而不是从零训练

目标检测从零训练需要 Imagenet 级别的预训练粮食,普通学生项目没有那个算力。常规做法是直接下载 COCO 预训练权重,从yolov8s.pt起手,冻结 backbone 先跑 30 轮,再解冻整体微调。把data.yaml写成下面这样:

path: ./dataset train: images/train val: images/val names: 0: person

这里names只放了一个类别person。注意nc不需要手动写,ultralytics 会自动根据names的长度推断。如果你手里的源码是基于 YOLOv5 的,那么格式类似,只是需要补一行nc: 1。类别 ID 为 0 意味着模型输出头的维度是(1 + 4 + 1) = 6每个 anchor,这与 COCO 的 80 类不同,不能直接拿 COCO 的推理脚本套结果。

4.2 训练命令中最值得调的四个参数

训练不是把命令丢进去就完事的环节,四个参数决定成败:imgsz、batch、epochs和patience。我常用的启动命令是这个样子:

yolo detect train \ model=yolov8s.pt \ data=dataset/data.yaml \ imgsz=640 \ batch=16 \ epochs=50 \ patience=10 \ seed=42 \ project=./runs \ name=seat_person

参数说明:imgsz=640是精度和速度的平衡点,教室画面是广角,人离得远,用 640 能捕捉到后排的小目标,但不要一上来就开 1280,那样显存占用翻四倍,而且对小目标未必有好处;batch=16在 8GB 显存左右的消费级显卡上比较合适,显存小就降到 8,否则一启动就 OOM;patience=10表示验证集指标连续 10 轮不提升就提前停止,这是避免过拟合的后悔药;seed=42是为了让实验可复现,方便你在技术文档里理直气壮地写出“本实验固定随机种子”。

训练跑完,看runs/seat_person/weights/best.pt这个文件是否存在。判断模型好坏先不看 mAP,直接看验证集上排数远的小目标 recall,如果后排经常漏检,优先检查是不是imgsz太小或者训练数据里后排样本占比太低。

4.3 导出 ONNX:让推理脱离 PyTorch,回到 OpenCV

训练完的模型是 PyTorch 权重,但我们要落地的推理脚本想尽量只依赖 OpenCV。这时候需要把模型导出为 ONNX,用 OpenCV 的 dnn 模块直接加载。导出命令:

yolo export model=runs/seat_person/weights/best.pt format=onnx opset=12 dynamic=True

opset=12是 OpenCV dnn 兼容性最好的算子集版本之一,太高或太低都可能触发不支持的算子;dynamic=True允许输入尺寸动态变化,这会带来一些部署上的灵活性。导出完成后,可以用 Netron 打开看一眼输出层名称,YOLOv8 的输出层名通常是/model.22/Concat_output_0,但不同版本会有差异,这直接影响后面 OpenCV 推理时forward的入参名。还有一个容易忽略的点:导出时 ultralytics 默认会把 NMS 也编译进去,用 OpenCV 加载时要选择不带 NMS 的版本,否则输出格式会很奇怪——常规做法是导出时保持默认的端到端输出,在 OpenCV 侧自己写非极大值抑制。

5. 用 OpenCV 做推理与座位标注:映射逻辑、阈值调节与踩坑

5.1 用 OpenCV dnn 读取 ONNX 模型并完成前处理

从这一章开始,标题里的“标注”正式出场:检测结果是矩形框,标注结果是“框 + 座位状态文字”。这段推理代码是整套管线里 OpenCV 含量最高的部分,我先给出完整的读取与推理函数:

import cv2 import numpy as np net = cv2.dnn.readNetFromONNX("best.onnx") def detect_person(image, conf_thresh=0.25, iou_thresh=0.45): h, w = image.shape[:2] blob = cv2.dnn.blobFromImage( image, scalefactor=1.0 / 255.0, size=(640, 640), mean=(0, 0, 0), swapRB=True, crop=False ) net.setInput(blob) outputs = net.forward() boxes, scores = [], [] # 输出形状为 [1, 84, 8400],前4个是xcycwh,第5个是类别0的得分 preds = outputs[0].transpose((1, 0)) # [8400, 84] for row in preds: class_score = row[4] if class_score < conf_thresh: continue xc, yc, bw, bh = row[:4] * np.array([w, h, w, h]) x1 = xc - bw / 2 y1 = yc - bh / 2 boxes.append([x1, y1, bw, bh]) scores.append(float(class_score)) idx = cv2.dnn.NMSBoxes(boxes, scores, conf_thresh, iou_thresh) return [boxes[i] for i in idx], [scores[i] for i in idx]

逻辑说明:blobFromImage把 BGR 图像转成模型需要的 NCHW 浮点张量,同时完成缩放和通道交换;net.forward()拿到的输出是[1, 84, 8400],其中 84 表示 4 个坐标 + 1 个类别概率 + 79 个 COCO 其它类概率,因为我们训练时只有 1 个类别,所以只看下标为 4 的通道。拿到坐标后再乘以输入图的宽高,把归一化坐标还原成像素坐标,最后用 OpenCV 内置的NMSBoxes做非极大值抑制,去掉重叠框。

参数说明:conf_thresh=0.25是 YOLO 系列的默认值,对教室场景来说偏低,后面会看到为什么我建议调高;iou_thresh=0.45控制重叠框的合并力度,数值越小,相邻的重复框越容易被合并且产生漏检。注意这里如果导出模型时设置的输入尺寸不是 640,size参数必须跟着改,否则宽高比拉伸后坐标全错。

5.2 座位区域标定:用透视变换把“图像坐标”对齐到“座位”

检测到的“人框”是图像坐标,教室里的“座位”也是图像坐标,按理说可以直接做交并比。但问题在于:摄像头位置只要动一点,座位区域就要重新标一整遍。更工程化的做法是用 OpenCV 的透视变换,把图像里的教室地面投影成一个俯视的平面图,然后在这个俯视图上预先画好座位矩形。这样换机位时只需要重新选四个角点,不需要重画几十个座位框。

操作分三步。第一步,运行下面这段脚本,手动点选教室地面的四个角点——通常对应第一排左侧、第一排右侧、最后一排右侧、最后一排左侧;第二步,指定这个四边形投影到俯视图后应占的宽高;第三步,得到的透视矩阵M保存成 numpy 文件,后面每次推理都用它。

pts_src = np.float32([[320, 260], [620, 280], [900, 480], [180, 470]]) pts_dst = np.float32([[0, 0], [800, 0], [800, 500], [0, 500]]) M = cv2.getPerspectiveTransform(pts_src, pts_dst) np.save("seat_transform.npy", M)

逻辑说明:pts_src是原图中教室地面的四个角点,顺序务必是“左上、右上、右下、左下”,顺序错了透视变换会出来一个拧成麻花的图;pts_dst是俯视图的尺寸,不用追求真实物理尺寸,只要比例接近教室的长宽比即可。变换矩阵M是一个 3x3 矩阵,它会把原图坐标映射到新坐标系。

参数说明:四个角点的选取如果精确性不够,座位框错位一二十个像素很正常,所以点击取点时放大到 200% 再点。也可以把这一步做成界面交互的:用cv2.setMouseCallback收集四个点击点,看到什么样的教室都能当场生成矩阵,这比硬编码坐标可靠得多。

5.3 人框与座位框求交并比:阈值是这套标注逻辑的核心

有了透视图,接下来把检测到的人框中心点用同一个矩阵M变换到俯视图,然后判断这个中心点落在哪一个座位矩形内。用中心点而不是整个人框做匹配,是因为人框包含手臂和身体两侧的空白,在透视缩小的座位区域里容易跨到相邻座。代码实现:

def map_person_to_seat(person_box, M, seat_rects): x, y, w, h = person_box center = np.float32([[[x + w / 2, y + h]]]) mapped = cv2.perspectiveTransform(center, M).reshape(-1) px, py = mapped[0], mapped[1] occupied = -1 for i, rect in enumerate(seat_rects): x1, y1, x2, y2 = rect if x1 <= px <= x2 and y1 <= py <= y2: occupied = i break return occupied

逻辑说明:先把人框的中心点取出来,再追加一个“脚底点”——也就是(x + w/2, y + h)。仅用中心点容易出事:前排坐着的人中心在靠背后面,会映射到后排座位的范围里去;而脚底点更贴近“这个人占的是哪个椅子面”的真实位置。实际使用中,我通常会同时投影两个点,任何一个点落在座位矩形内就判定占用,两个点都横跨座位边界时取 IoU 更大的一方。

参数说明:如果发现“站着的人”也被判定为占用座位,那是因为脚底点转换后落在座位上而实际人站在过道。解决方式是给每个座位区域再加一个高度约束:把座位矩形缩窄 20%,宁可漏掉边缘,也不要误判。这类“宁可漏、不可错”的阈值取向,在监考场景里比追求全对更重要——漏报一个空座还能人工复核,误报一个空座是没法解释的。

5.4 画框标注:输出可直接用于文档和演示的画面

最后一步是把结果画回原图,这就是标题里“标注”的直观体现。用 OpenCV 画框和写文字时,中文字体会变成乱码,所以状态文字我一般用英文。代码:

def draw_seat_status(image, seat_rects, occupied_idx): for i, rect in enumerate(seat_rects): x1, y1, x2, y2 = rect color = (0, 0, 255) if i in occupied_idx else (0, 255, 0) label = "occupied" if i in occupied_idx else "free" cv2.rectangle(image, (x1, y1), (x2, y2), color, 2) cv2.putText(image, f"seat_{i}:{label}", (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2)

逻辑说明:occupied 用红色框,free 用绿色框,颜色正好符合中国场景里“红停绿行”的直觉。putText的字体如果嫌小,可以把 0.6 调到 0.8,但要注意框的粗细和字体大小需要同步提升,否则画面看起来头重脚轻。

参数说明:cv2.putText只支持 ASCII 字符,中文需要用 PIL 转 ImageDraw 才能画。如果需要输出中文报表,我建议另存一份 CSV 而不是直接在图上画中文,因为视频帧序列变化时,中文渲染的开销会让推理帧率掉到不可用的程度。

5.5 避坑记录:这套流程最容易翻车的五个地方

现象一:后排学生永远漏检,前排框又大又准。原因是教室是俯视视角,后排人头的像素高度只有前排的三分之一,单一尺度训练模型照顾不到这么大的尺寸差异。解决:把原图分成上半部分和下半部分两个区域分别推理,图像尺寸保持 640,让每个区域里的目标占比更接近,再合并结果。合并时注意两条边界线重叠 50 像素,防止人刚好被切到分界线上。

现象二:同一个座位上的人在三帧里从“占用”变成“空闲”。原因是检测器输出置信度抖动,conf_thresh=0.25太低,边缘姿态的框时而通过、时而被滤掉。解决:把置信度提到 0.4,并加入简单的状态平滑——连续三帧里至少两帧判定为占用,才最终标记为 occupied。这个“滞后判断”比调模型省事得多。

现象三:导出 ONNX 后 OpenCV 推理输出全是非线性分布的坐标,画框飞到画面外。原因是 OpenCV 的readNetFromONNX对不同导出器生成的坐标顺序敏感,有的模型输出xywh,有的输出xyxy。解决:不要猜,先从输出里取一行的前四个数,打印出来比对原始图像尺寸,确定是哪种形式再写解析。这是调 OpenCV dnn 最容易踩的暗坑。

现象四:同一套代码在另一台机器上跑出来的框位置偏移了几个像素。原因是 OpenCV 的版本不一致,blobFromImage的swapRB行为在不同版本中出现了差异。解决:项目用requirements.txt锁定版本,尤其锁opencv-python==4.8.1.78,不要用pip install opencv-python装出最新版就直接上线。

现象五:穿白衬衫的人坐在白墙前面,模型要么认不出、要么框切掉了半个身体。原因是训练数据里这种情况占比太少,模型没有专门学“低对比度人形”。解决:采集数据时特意让现场同学换几件浅色衣服,再做随机亮度扰动和对比度扰动。如果项目时间紧,加一种数据增强,例如 HSV 通道随机调亮度 ±30%,比增加 100 张图更见效。

6. 进阶技巧与验收方法:让这套识别真正交付出去

6.1 用跟踪消除重复计数

教室场景的下一步需求通常是“统计一节自习课来了多少人、哪些座位被反复使用”。单帧检测做不到这件事,因为它把一个在座位上挪动了两次的人识别成两个独立目标。给检测结果加一个轻量的 IoU 跟踪器,为每个目标分配 track_id,缓存最近 20 帧的位置。当新检测框与缓存里某个 track_id 的 IoU 大于 0.3 时,视为同一个人的延续而不是新目标。这个逻辑用普通字典就能实现,不需要引入第三方跟踪库。输出统计时按 track_id 去重,再配合座位映射,就能画出“这个座位这一小时被占用过多少次”的热力图。

6.2 把最终验证落到指标上

验收时不要只肉眼看好不好看,准备一批从未参与训练的标注帧,人工标一下真实座位占用状态,然后算三个指标:精确率(标记 occupied 的座位里真实有人占比)、召回率(真实有人的座位里被标记出的占比)、以及 F1。精确率低说明误报多,检查透视变换矩阵和座位矩形是否标得太宽;召回率低说明漏检多,检查后排检测框和置信度阈值。最后一个我在交付时必做的动作是把处理后的视频导出成 MP4,用正常播放速度看一遍——这一步能一次性暴露所有“单帧看着没事、连续播放疯狂抖动”的问题。

6.3 让源码从“能跑”变成“可维护”

整理这类项目源码时,我会保留一个顶层配置文件config.yaml,把相机拍到的教室四个角点、座位行列数、置信度阈值、保存路径全放在里面,而不是散落在代码里。训练、转格式、推理三个环节分别对应prepare_data.py、train.sh、infer.py,新接手的人看目录结构就能明白项目意图。至于标题上的“使用文档”,最值得写进去的不是框架介绍,而是“换教室后你必须改哪四个地方”:角点坐标、座位矩形、拍摄机位建议、亮度阈值。这套项目我前后迭代过三个版本,最大的教训就是早期把座位矩形硬编码进推理脚本,结果每次换机位都要在代码里找坐标,后来全部外置到配置文件并画辅助调试图,改动成本降到原来的十分之一。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询