简介:该资源为基于Python的SSD空停车位识别演示项目源码,面向深度学习入门者、目标检测学习者及智能停车场系统开发者,帮助理解SSD算法在真实场景中的落地流程。压缩包共78个文件,约282KB,以61个Python源码文件为主体,覆盖数据预处理、模型训练、推理评估与客户端-服务器部署等模块;另含5个XML配置、2个txt说明及yaml、jpg等辅助文件,用于网络结构定义、参数配置与使用指引。项目围绕SSD目标检测展开,包含backbone、box_head、detector等建模组件,以及anchors、nms、box_utils等检测工具,并给出demo、server、client等脚本,展示实时查看停车位检测结果的简单架构。目前已有314人学习,适合通过阅读源码掌握数据集标注、模型训练调参与系统部署的完整思路,也可作为智能交通与智慧城市场景的实践参考。
1. 从一张俯拍图说起:SSD 空停车位识别到底在做什么
地下车库的摄像头架在通道上方,画面里一排车位有的停着车,有的空着。人眼一眼能分辨,但要让程序自动数出「还剩几个空位」,就没那么简单了——车位线被柱子遮挡、相邻车位只隔一条白线、光照忽明忽暗、车头压线、地锁立起来……这些都是真实场景里天天遇到的麻烦。基于 Python 的 SSD-Demo 空停车位识别,要解决的就是这件事:用 SSD 目标检测算法,把画面里的「空车位」和「占用车位」分别框出来,输出可用的车位状态。
这个方向适合三类人:一是做智慧停车、园区管理的开发者,需要一套能跑通、能改的检测基线;二是学目标检测的学生和转行者,想找一个比猫狗分类更贴近业务的练手项目;三是已有摄像头资源、想低成本验证算法可行性的工程师。SSD(Single Shot MultiBox Detector)的特点是单阶段、速度快、对小目标有一定容忍度,配合 Python 生态里的 PyTorch 或 PaddlePaddle,能在普通显卡甚至 CPU 上跑出可接受的帧率。源码层面,这类 Demo 通常包含数据集标注、模型定义、训练脚本、推理脚本四块,理解清楚这四块,你就能把它改成自己的车位识别系统。
2. SSD 检测空车位的原理与选型:为什么不用分类网络硬套
2.1 空车位识别本质是检测问题,不是分类问题
很多人第一反应是「裁出每个车位的小图,丢进分类网络判断空/满」。这个思路在固定机位、车位框位置不变时能work,但一旦摄像头有轻微位移、车位被遮挡、或者要换一个停车场,整套坐标就得重标,维护成本极高。更麻烦的是,分类网络只能告诉你「这张小图里有没有车」,它不知道车停歪了、压了两个车位、或者车位里堆了杂物。
SSD 这类目标检测网络直接输出边界框和类别,把「空车位」当成一个类别去学。它的好处是:车位不需要预先切好,网络自己在整张图里找;车位被部分遮挡时,只要特征还在,仍有机会检出;换场景时重新标注一批数据微调即可,不用改代码结构。从工程角度看,这是更可持续的方案。
SSD 的核心设计有三点值得记住。第一,多尺度特征图预测:在 6 个不同分辨率的特征图上分别设置默认框(prior box),浅层特征图负责小目标,深层负责大目标。第二,默认框机制:每个特征图位置预设多种长宽比的框,网络只预测相对这些框的偏移量,收敛更快。第三,硬负样本挖掘:正负样本极度不均衡时,按置信度损失排序,挑最难分的负样本参与训练,避免模型被大量背景带偏。
2.2 主干网络与输入尺寸怎么选
SSD 常见主干是 VGG16 或 ResNet,也有轻量化的 MobileNet 版本。车位识别场景里,我一般推荐先用 MobileNet-SSD 跑通,原因是车位目标通常不大、场景相对固定,轻量主干足够,而且推理快,方便部署到边缘设备。如果检出率不达标,再换 VGG16-SSD 或加 FPN 结构。
输入尺寸直接决定小目标检出能力。SSD300 对小目标偏弱,SSD512 明显更好,但显存和耗时上升。车位在俯拍图里往往只占几十个像素,建议从 512 起步。下面是一个基于 PyTorch 的 SSD 配置片段,展示输入尺寸和默认框设置的关键参数:
# ssd_config.py # 输入尺寸:车位目标小,选 512 而非 300 IMAGE_SIZE = 512 # 默认框配置:每个特征图上的尺度与长宽比 # 车位多为横向矩形,长宽比里保留 2 和 3 PRIOR_BOX_CONFIG = { "feature_maps": [64, 32, 16, 8, 4, 2, 1], # 7 层特征图 "min_sizes": [35.84, 76.8, 153.6, 230.4, 307.2, 384.0, 460.8], "max_sizes": [76.8, 153.6, 230.4, 307.2, 384.0, 460.8, 537.6], "aspect_ratios": [[2], [2, 3], [2, 3], [2, 3], [2], [2], [2]], "variance": [0.1, 0.2], } # 类别:0 背景,1 空车位,2 占用车位 NUM_CLASSES = 3这段配置里,feature_maps是各层特征图的空间尺寸,min_sizes和max_sizes控制默认框的绝对像素大小,aspect_ratios决定框的形状。车位是扁长矩形,所以长宽比保留 2 和 3 就够了,不需要 1:1 之外的太多比例。variance是偏移量缩放系数,一般保持默认。改这些参数时,最直接的验证方式是看训练初期正样本匹配数量——如果匹配数太少,说明默认框和真实车位框差距太大,需要调min_sizes或aspect_ratios。
2.3 数据集标注:空车位和占用车位要分开标
标注质量决定上限。空车位识别里,我建议把「空车位」和「占用车位」都标出来,而不是只标空车位。原因有两个:一是网络能学到两者的边界差异,减少把地面反光误判成空位;二是推理时可以直接输出两类框,方便统计总数。
标注格式用 VOC 的 XML 或 YOLO 的 txt 都行,关键是类别名统一。下面是一个把 VOC 标注转成 SSD 训练所需格式的脚本片段:
# voc_to_ssd.py import os import xml.etree.ElementTree as ET # 类别映射:背景固定为 0 CLASS_MAP = {"empty": 1, "occupied": 2} def parse_voc(xml_path): tree = ET.parse(xml_path) root = tree.getroot() size = root.find("size") w = int(size.find("width").text) h = int(size.find("height").text) boxes = [] for obj in root.iter("object"): name = obj.find("name").text if name not in CLASS_MAP: continue bbox = obj.find("bndbox") xmin = float(bbox.find("xmin").text) / w ymin = float(bbox.find("ymin").text) / h xmax = float(bbox.find("xmax").text) / w ymax = float(bbox.find("ymax").text) / h boxes.append([CLASS_MAP[name], xmin, ymin, xmax, ymax]) return boxes # 输出为每行:class_id xmin ymin xmax ymax(归一化) def convert(voc_dir, out_txt): with open(out_txt, "w") as f: for xml_file in os.listdir(voc_dir): if not xml_file.endswith(".xml"): continue boxes = parse_voc(os.path.join(voc_dir, xml_file)) for b in boxes: f.write(" ".join(str(x) for x in b) + "\n")转换逻辑很直接:读 XML,取宽高做归一化,按类别映射成整数。注意归一化用的是整图宽高,不是框自身宽高,这是 SSD 训练输入的要求。如果标注里有difficult标记,建议先跳过,避免难样本干扰初期训练。
3. 从零跑通 SSD-Demo:环境、训练、推理三步走
3.1 环境搭建与依赖版本锁定
Python 环境建议 3.8 到 3.10,太新的版本有时和旧版 PyTorch 冲突。显卡驱动、CUDA、PyTorch 三者版本要对应,这是最常见的翻车点。我一般用 conda 建独立环境,避免污染系统 Python。
# 创建环境 conda create -n ssd_parking python=3.9 -y conda activate ssd_parking # 安装 PyTorch(以 CUDA 11.8 为例,具体按显卡驱动选) pip install torch==2.0.1 torchvision==0.15.2 --index-url https://download.pytorch.org/whl/cu118 # 其余依赖 pip install opencv-python numpy matplotlib tqdm pillow装完后务必验证 CUDA 是否可用:
import torch print(torch.__version__) print(torch.cuda.is_available()) # 应为 True print(torch.cuda.get_device_name(0))如果is_available()返回 False,先别急着改代码,九成是驱动或 CUDA 版本不匹配。用nvidia-smi看驱动支持的 CUDA 上限,再回来看 PyTorch 版本。这一步没搞定,后面训练会直接报错或静默退回 CPU,速度差几十倍。
3.2 训练脚本的关键参数与启动命令
训练脚本通常包含数据加载、模型构建、损失计算、优化器、学习率调度五部分。SSD 的损失由分类损失和定位损失加权组成,权重比一般设 1:1 到 1:2。下面是一个训练循环的核心片段:
# train.py import torch from torch.utils.data import DataLoader from ssd_model import SSD512 from dataset import ParkingDataset from multibox_loss import MultiBoxLoss device = torch.device("cuda" if torch.cuda.is_available() else "cpu") # 数据集:训练集和验证集按 8:2 划分 train_set = ParkingDataset(root="data/train", image_size=512, augment=True) val_set = ParkingDataset(root="data/val", image_size=512, augment=False) train_loader = DataLoader(train_set, batch_size=8, shuffle=True, num_workers=4) val_loader = DataLoader(val_set, batch_size=8, shuffle=False, num_workers=4) model = SSD512(num_classes=3).to(device) criterion = MultiBoxLoss(num_classes=3, overlap_thresh=0.5, neg_pos_ratio=3) optimizer = torch.optim.SGD(model.parameters(), lr=1e-3, momentum=0.9, weight_decay=5e-4) scheduler = torch.optim.lr_scheduler.MultiStepLR(optimizer, milestones=[80, 120], gamma=0.1) for epoch in range(150): model.train() for images, targets in train_loader: images = images.to(device) targets = [t.to(device) for t in targets] optimizer.zero_grad() out = model(images) loss_l, loss_c = criterion(out, targets) loss = loss_l + loss_c loss.backward() optimizer.step() scheduler.step() # 每 10 轮验证一次 if epoch % 10 == 0: model.eval() # 验证逻辑略,重点看 mAP 和空车位召回率 torch.save(model.state_dict(), f"weights/ssd_epoch_{epoch}.pth")参数说明:batch_size=8是 512 输入下的保守值,显存 8G 以上可以试 16;overlap_thresh=0.5是正样本匹配的 IoU 阈值,低于它算负样本;neg_pos_ratio=3表示负样本最多是正样本的 3 倍,这是硬负样本挖掘的常用比例;学习率用 SGD 从 1e-3 起步,80 和 120 轮各降一次。启动命令就是python train.py,但建议加日志重定向,方便回看。
训练时重点盯两个指标:总 loss 是否稳定下降,以及验证集上空车位的召回率。如果 loss 降但召回不涨,多半是正样本匹配太少,回去调默认框配置。
3.3 推理与可视化:把框画回原图
推理脚本要做三件事:预处理输入、前向计算、NMS 后处理并画框。下面是一个可复用的推理片段:
# inference.py import cv2 import torch import numpy as np from ssd_model import SSD512 device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = SSD512(num_classes=3).to(device) model.load_state_dict(torch.load("weights/ssd_final.pth", map_location=device)) model.eval() CLASSES = ["background", "empty", "occupied"] COLORS = [(0, 0, 0), (0, 255, 0), (0, 0, 255)] # 空位绿,占用红 def preprocess(img, size=512): img_resized = cv2.resize(img, (size, size)) img_rgb = cv2.cvtColor(img_resized, cv2.COLOR_BGR2RGB) tensor = torch.from_numpy(img_rgb).float().permute(2, 0, 1) / 255.0 mean = torch.tensor([0.485, 0.456, 0.406]).view(3, 1, 1) std = torch.tensor([0.229, 0.224, 0.225]).view(3, 1, 1) return (tensor - mean) / std def detect(img_path, conf_thresh=0.4, nms_thresh=0.45): img = cv2.imread(img_path) h, w = img.shape[:2] tensor = preprocess(img).unsqueeze(0).to(device) with torch.no_grad(): detections = model(tensor) # detections 需经 NMS,此处省略具体实现 # 画框时把归一化坐标乘回原图宽高 for det in detections: cls_id, score, xmin, ymin, xmax, ymax = det if score < conf_thresh: continue x1, y1 = int(xmin * w), int(ymin * h) x2, y2 = int(xmax * w), int(ymax * h) cv2.rectangle(img, (x1, y1), (x2, y2), COLORS[int(cls_id)], 2) cv2.putText(img, f"{CLASSES[int(cls_id)]} {score:.2f}", (x1, y1 - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, COLORS[int(cls_id)], 1) cv2.imwrite("result.jpg", img) detect("test_parking.jpg")conf_thresh=0.4是置信度阈值,低于它的框直接丢;nms_thresh=0.45控制重叠框合并,车位密集时可以调到 0.3 到 0.4,避免相邻车位被误合并。画框时坐标要乘回原图宽高,因为训练时做了归一化。这一步如果忘了,框会全部挤在左上角,是新手最常见的翻车现场。
4. 空车位识别的避坑与排查:那些让模型「看起来能跑」的陷阱
4.1 现象:训练 loss 正常下降,但推理时一个框都不出
原因通常有三个:一是推理时的预处理和训练不一致,比如训练用了 ImageNet 均值方差,推理忘了减;二是类别数对不上,训练时num_classes=3,推理加载的模型却是 2;三是 NMS 阈值设得太低,把所有框都滤掉了。解决方法是先打印原始输出,看有没有置信度大于 0.1 的框,再逐步排查预处理和类别映射。
4.2 现象:空车位和占用车位频繁互换
这是标注不一致导致的。同一个车位,上午标成 empty,下午标成 occupied,网络学到的特征就混乱了。解决方法是标注时统一标准:车位内没有车头、车尾、车轮可见,就算 empty;只要压线或部分进入,就算 occupied。另外,地锁、锥桶这类物体建议单独标一类或直接忽略,不要混进空车位。
4.3 现象:相邻车位被合并成一个大框
SSD 的默认框长宽比如果偏向正方形,横向相邻的两个车位容易被一个框覆盖。解决方法是把aspect_ratios里的 2 和 3 保留,同时把 NMS 阈值从 0.45 降到 0.35 左右,让重叠框更容易被分开。如果还不行,就在后处理里加一条规则:框的宽高比超过 3 的,拆成两个。
4.4 现象:夜间或逆光场景检出率骤降
这是数据分布问题,不是模型问题。训练集里如果全是白天图,夜间必然翻车。解决办法是采集夜间样本重新微调,或者在预处理里加自适应直方图均衡化(CLAHE)。我一般会在数据增强里加随机亮度、对比度扰动,让模型对光照变化更鲁棒。
4.5 现象:换一个停车场,模型完全失效
车位线样式、地面材质、摄像头角度都变了,模型没见过。这时候不要重训,先拿新场景的几十张图做微调,冻结主干只训检测头,学习率降到 1e-4,通常几十轮就能恢复。如果差异太大,就重新标注一批数据从头训。
5. 把 SSD-Demo 用起来:从单帧检测到视频流统计的进阶技巧
跑通单张图只是起点,真正有价值的是把它接到视频流上,实时统计空车位数量。这里有一个我常用的技巧:不要每帧都跑检测,而是隔帧检测加跟踪补偿。车位是静止的,车进出是低频事件,每 5 帧检测一次,中间帧用上一帧的结果,能省下大量算力。
# video_pipeline.py import cv2 import time cap = cv2.VideoCapture("parking_lot.mp4") frame_id = 0 last_result = None DETECT_INTERVAL = 5 # 每 5 帧检测一次 while True: ret, frame = cap.read() if not ret: break if frame_id % DETECT_INTERVAL == 0: last_result = detect_and_count(frame) # 返回空车位数量 # 在画面上叠加最近一次统计结果 cv2.putText(frame, f"Empty: {last_result}", (30, 50), cv2.FONT_HERSHEY_SIMPLEX, 1.2, (0, 255, 0), 2) cv2.imshow("parking", frame) if cv2.waitKey(1) & 0xFF == ord("q"): break frame_id += 1 cap.release() cv2.destroyAllWindows()这个管道的参数DETECT_INTERVAL要根据实际帧率和算力调。30fps 的视频,间隔 5 帧就是每秒检测 6 次,对车位状态来说足够。如果画面里车流很快,就降到 3。
另一个实用技巧是给每个车位分配固定 ID。方法是在第一次检测后,把框的中心点存下来,后续帧用最近邻匹配。这样统计时不会因为框的轻微抖动而重复计数。代码上可以用一个简单的字典维护{车位ID: 最近中心点},每帧更新。
验证模型好不好,别只看 mAP。空车位识别更该看两个业务指标:空车位漏检率(把空位判成占用)和误检率(把占用判成空位)。漏检率高,用户到了发现没位,体验差;误检率高,系统显示有位实际没有,更糟。我一般要求漏检率低于 5%,误检率低于 3%,达不到就回去补数据。
最后说一个血泪教训:别在训练集上验证。我见过太多人把训练图直接拿来测,框画得漂漂亮亮,一上真实视频就崩。验证集必须和训练集在时间、天气、摄像头上都有区分度,否则你看到的只是模型在背答案。这个习惯我坚持了很多年,希望帮到你。
本文还有配套的精品资源,点击获取