简介:这是一个基于Python与SSD深度学习算法的空停车位识别演示项目,面向智能交通、智慧停车及目标检测初学者,解决停车场空位自动检测与可视化问题。压缩包共78个文件、约282KB,以61个Python源码为主体,配合XML/YAML配置文件、IDE工程文件、Git管理文件与演示图片,覆盖SSD模型配置、数据预处理、训练推理、客户端-服务器交互等完整模块。目前已有314人学习。项目给出从模型搭建到部署的完整调用链,客户端、服务端与演示脚本展示了客户端-服务器架构下的实时车位检测流程,锚框生成、骨干网络、检测头等核心模块则便于拆解SSD网络结构;配合说明文档与配置文件,可快速复现训练与推理过程。这套代码既能用于算法实验,也能迁移到实际停车场场景中;相比零散示例,目录结构清晰,便于按模块阅读和二次开发,是理解目标检测工程化流程的实用素材。
1. 停车场的“空”车位为什么让目标检测集体翻车
停车场的俯视相机里,空车位是全场最没有“内容”的区域——没有车、没有人,只有两条发灰发旧的白线围出一个低纹理矩形。白天阳光足的时候,基于Python的SSD-Demo还能把这些矩形当目标框出来;一到傍晚、雨天,或者旁边停进一辆白色车,漏检和误检就成对出现。这套Demo真正要解决的问题,是把“空”这个弱语义变成可训练的检测类别,再配一套能落地的算法设计和后处理,让它在固定机位的停车场里可靠地跑出结果。这篇文章按选型、训练、踩坑、推理、进阶的顺序讲透一套可复现的做法,适合手里有停车场图像数据、想自己训练识别算法的人,也适合刚跑通目标检测、正想拿真实场景练手的新手。
2. 为什么是SSD这棵树:多尺度先验框与细长车位框的匹配逻辑
2.1 三条技术路线对比:直接检空、检车位再分类、固定坐标映射
空车位识别看起来是一个检测问题,但实际落地时有三种常见做法,差别不是一星半点。
- 路线A:把“空车位”当作一个类别,用SSD直接输出空车位框。
- 路线B:用SSD输出“车位”类别(不论空不空),再对每个检测框做占用/空闲二分类。
- 路线C:不训练检测器,利用停车位坐标固定的先验,把每个车位映射到图像坐标,抠出固定区域后判空。
| 路线 | 模型负担 | 优点 | 痛点 | 适用场景 |
|---|---|---|---|---|
| A 直接检空 | SSD只输出空车位 | 流程最短,Demo效果好 | “空”特征弱,漏检偏高 | 视角固定的小型停车场 |
| B 检车位+判空 | SSD+轻量分类器 | 定位任务简单,召回高 | 多一个分类模型要维护 | 白天夜晚都在跑的生产环境 |
| C 固定映射 | 无检测器,只做判空 | 最稳、计算量极小 | 依赖相机不移动 | 杆架/收费相机固定场合 |
SSD-Demo的源码通常走路线A或B,两者差别只在类别定义和标注方案上。我的建议是:如果你做的是Demo,先按路线A出效果;如果要做成能连续运行的系统,至少改成B。纯路线C虽然工程上最可靠,但和“SSD检测”这个标题就没什么关系了,这里不展开。
不少人来问我为什么不用YOLO。YOLO当然能做车位检测,但SSD-Demo在这类固定场景里的优势是它对先验框的几何设计高度透明——你完全清楚每个框长什么样、盖在哪个特征层上,出了问题能直接对着先验框可视化排查。YOLO的anchor同样可以自定义,但v8之后的anchor-free结构让“预置几何先验”这个语义弱化了,在某些细长目标上反而不如显式先验直观。所以这套源码选择SSD,本质上选的是可解释性和容易调试。等你真的上线排一周错,就知道“能对着anchor可视化”是多么救命。
2.2 SSD的多尺度特征与先验框:让先验框贴合车位投影
SSD的核心思想说穿了就一句:在多个尺度的特征图上预先铺一批先验框,然后让网络去修正这些框的位置和大小。先验框设计得好不好,直接决定一个细长目标能不能被“锚”到。
具体到SSD-Demo,常见结构是从VGG16(或ResNet)的多个层引出特征图,比如conv4_3、conv7、conv8_2、conv9_2、conv10_2、conv11_2,对应分辨率从38×38一路降到1×1。空车位在画面里的尺寸跨度非常大:近处车位宽可能占画面一半,远处车位只有二三十像素,所以必须让多个尺度的先验框都能参与匹配,而不是依赖某一层。
标准SSD在每个特征图格子上铺的默认长宽比是{1, 2, 3, 1/2, 1/3},这个组合对行人、汽车、狗这类常见目标够用,但对空车位并不舒服。停车位按常见民用停车场算,大约2.5m宽、5.0m深,长宽比接近2:1;如果相机带斜视角度,投影到图像上的形状会变成1.5:1到4:1之间变化的长矩形,透视严重的甚至到5:1。所以我一般会在anchor配置里把长宽比扩展成{1, 1.5, 2, 3, 4, 1/2, 1/3},同时把先验框尺寸的两档间距放小一些,让近处车位和远处车位都有对应的框覆盖。
匹配阈值也要跟着动。SSD默认用IoU大于0.5的先验框作为正样本,这个值对车位这类框线较细的目标稍高——因为空车位区域里真正有纹理的只有边线,框内大面积是空白,先验框与标注框的IoU很容易落在0.4到0.5之间。我一般会把正样本匹配阈值降到0.45,同时在loss里把坐标回归的权重调低一点,避免大量“勉强匹配”的框去抢夺分类头部的学习空间。这个参数比调学习率对最终效果的影响更直接,值得多试几轮。
2.3 环境准备与数据集目录结构:先按VOC格式把地基搭好
开始动手前,先确定环境。Python安装之后第一件事不是pip一堆包,而是建虚拟环境:
python -m venv venv source venv/bin/activate # Windows下是 venv\Scripts\activate pip install torch torchvision opencv-python numpy tqdm这一组依赖就够了。torch和torchvision提供SSD骨干和基础算子,opencv负责读图与后处理,numpy做数组运算,tqdm只在训练时看进度条用。SSD-Demo这类源码的组织方式通常按VOC格式来,因为标注工具导出的是VOC的XML结构,解析代码也是现成的。
建议的目录结构:
ssd-demo/ data/ VOC2007/ JPEGImages/ # 原始停车图片 Annotations/ # 每张图对应的XML标注 ImageSets/ Main/ train.txt # 训练图片名列表 val.txt # 验证图片名列表 models/ # 骨干网络与SSD头定义 utils/ # 数据增强、prior box生成、NMS checkpoints/ # 训练得到的权重文件目录结构本身没有玄学,关键是让训练脚本、验证脚本和推理脚本都从同一份文件清单里读数据。train.txt和val.txt每行写一张图的名字(不带后缀),训练集和验证集按8:2切。切的时候要注意:同一根相机杆、同一时间段拍的连续帧尽量放在同一边,否则验证集会被“记忆”得异常好看,上线后立刻现原形。
3. 训练一个空车位SSD检测器:XML标注、数据转换和关键参数
3.1 LabelImg标注规范:空车位正样本怎么标才不坑后端
标注工具用LabelImg最常见,因为它导出VOC格式XML是直接的,和SSD-Demo的读取逻辑无缝衔接。标注规范比工具本身重要得多,我踩过的几个大坑全出在标注规则不统一上。
第一,框要紧贴车位线的内侧,而不是把整条线包进去。因为SSD学到的是“两条线之间的空区域”这个语义,把线本身包进去,会让模型以为“有线”和“空”必须同时出现,一旦线磨损,整个目标就丢了。第二,被车辆占用一半的车位不要标。那种“半个车头伸进车位”的框,对网络来说是灾难,它会学到一半车也算空位。第三,一条路上连续几帧的画面不要全标,隔几帧选一张,否则训练集里同一场景的相似图占了四成,验证精度虚高。
标注规模的参考值:空车位框累计2000个以上。负样本(背景、占用车位)不用单独标,SSD会把所有没匹配上先验框的区域当成背景参与hard negative mining,所以负样本本质上是够的。如果发现某个天气状况漏检特别严重,回去补这个天气下的标注图,比调任何参数都有效。
3.2 把XML喂给Pytorch:一个自定义Dataset的写法
SSD-Demo源码里通常已经写好了数据读取模块,但理解这个数据结构还是有必要的,因为你要往里面塞自己的类别和坐标。最常用的做法是写一个Pytorch Dataset,在__getitem__里做“读图、解析XML、归一化坐标、resize”四件事:
import xml.etree.ElementTree as ET import numpy as np import torch from torch.utils.data import Dataset from PIL import Image class ParkingSlotDataset(Dataset): def __init__(self, img_dir, ann_dir, image_set, size=(300, 300)): self.img_dir = img_dir self.ann_dir = ann_dir self.size = size # 从train.txt/val.txt读图片名,每行一个 with open(image_set) as f: self.ids = [line.strip() for line in f if line.strip()] def __len__(self): return len(self.ids) def __getitem__(self, idx): img_id = self.ids[idx] img = Image.open(f"{self.img_dir}/{img_id}.jpg").convert("RGB") boxes, labels = self._parse_xml(f"{self.ann_dir}/{img_id}.xml") # resize到网络输入,坐标跟着等比缩放 w, h = img.size img = img.resize(self.size) scale_x = self.size[0] / w scale_y = self.size[1] / h boxes = boxes * torch.tensor([scale_x, scale_y, scale_x, scale_y]) # PIL转tensor:HWC -> CHW return torch.from_numpy(np.asarray(img)).permute(2, 0, 1).float(), boxes, labels def _parse_xml(self, path): boxes, labels = [], [] root = ET.parse(path).getroot() for obj in root.findall("object"): name = obj.find("name").text # 只处理空车位类别,其余目标直接跳过 if name != "free_parking_space": continue b = obj.find("bndbox") x1 = float(b.find("xmin").text) y1 = float(b.find("ymin").text) x2 = float(b.find("xmax").text) y2 = float(b.find("ymax").text) boxes.append([x1, y1, x2, y2]) labels.append(1) # 背景类别是0,空车位类别是1 return torch.tensor(boxes, dtype=torch.float32), torch.tensor(labels)代码的逻辑是:从txt读图片清单,按id找到jpg和xml,解析出bounding box和标签,然后统一resize到固定尺寸。注意坐标必须从原图尺寸等比缩放到模型输入尺寸,否则回归头学到的是错位的偏移;permute(2,0,1)是把PIL的HWC布局转成Pytorch的CHW,float()是为了满足卷积输入类型。
这个Dataset里刻意只保留“free_parking_space”这个正类,背景类在SSD内部由先验框自动生成,不需要显式标注。如果你要改成路线B(检测所有车位再判空),只需要把类别名换成“parking_slot”,并在模型配置里把类别数从2改成3,其他结构不变。这是SSD-Demo最容易扩展的位置。
3.3 训练参数表:把参考值抄下来,再按显存微调
训练参数是最容易抄作业的部分,也最容易一眼翻车。以下是我的常用配置,先照抄再微调:
| 参数 | 参考值 | 说明 |
|---|---|---|
| 骨干网络 | VGG16(ImageNet预训练) | SSD-Demo默认结构,换ResNet要改对应层的anchor分配 |
| 输入尺寸 | 300×300 或 512×512 | 300快、512准,后面部署按设备选 |
| batch size | 16(300输入)/ 8(512输入) | 显存不够先减半,别硬撑 |
| 优化器 | SGD,momentum=0.9,weight_decay=5e-4 | Adam也能训,SGD的最终精度更可控 |
| 初始学习率 | 0.001(带warmup) | 迭代2000步后线性升到0.01,比固定0.01稳 |
| 学习率衰减 | 40/60 epoch各降10倍 | 或cosine退火,二选一即可 |
| 正样本匹配IoU | 0.45~0.5 | 空车位先验框匹配率低,0.5不行就降 |
| 负正样本比 | 3:1 | hard negative mining按loss取top样本 |
| 数据增强 | 随机裁剪、颜色抖动、水平翻转 | 加一块随机黑斑模拟遮挡更贴近现场 |
| 总epoch | 50~80 | 数据少就加epoch并早停 |
调参顺序比参数值本身更重要。不要第一天就调学习率,先跑一轮看loss曲线和验证集状态:如果验证mAP低、训练mAP高,是过拟合或数据同源太多;如果两者都低但loss在降,多半是anchor匹配率太低,去调匹配IoU和先验框长宽比;如果训练loss快乐下降、验证loss纹丝不动,先检查数据集是否切分正确。
训练结束把best模型单独存一份,后续推理、转换都用这一份权重,不要在训练日志里随手抓。这个习惯能省掉大量“线上效果和验证结果对不上”的血泪排查时间。
4. 空车位识别最常见的5个翻车点排查
空车位识别和普通目标检测不一样,它的坑集中在“空”这个状态的弱特征上。下面5条是我的踩坑记录,按现场出现频率排序,每一条都是现象、原因、解决三段讲清。
4.1 白天正常、傍晚崩盘:光影变化把“空”的特征剥掉了
现象:晴天正午验证mAP在0.85附近,傍晚6点一过漏检率暴涨,部分空车位完全不出框。
原因:空车位只有两条边线加一块低纹理地面,这种弱特征极度依赖光照。傍晚阳光斜射,地面反光、阴影方向发生变化,线的对比度降到背景噪声级别,模型相当于在“猜”。
解决:标注阶段强制加入黄昏、阴天样本;训练增强里把亮度、对比度抖动范围拉大;如果相机支持,考虑按时间段切两套曝光参数,分别出图再送同一个模型。不要指望模型自己“学会”不同光照,它只会先学背景。
4.2 白色车身反光被识别成车位线,多出一堆假空位
现象:白色车停在普通路面上,车身侧面的高光区域产生两条竖直亮线,模型在车身边缘给出一个置信度0.6以上的空车位框。
原因:SSD学的其实是“低纹理矩形+左右边缘线”的组合,白色车身的轮廓和反光在语义上恰好满足了这两个视觉线索。
解决:后处理加几何约束——输出框的长宽比超过5:1或小于1:1.2的丢弃;框内区域做边缘密度统计,如果两条“车位线”中间的纹理方差太高,说明里面可能有内容,不可能是空位。这个约束在推理端做,完全不用重新训练。
4.3 相邻车位停进一辆车,空车位框跟着被挤歪
现象:左右两个车位,左边空、右边停进SUV,模型输出一个覆盖两车位的宽框,或者空车位框向旁边偏移半米。
原因:SSD在低层特征图上回归位置,相邻目标的强响应会拉偏先验框的坐标回归。车位这种相邻排列的目标特别容易被“统一起方框”。
解决:匹配IoU提到0.5以上,减少低质量正样本参与坐标回归;在loss里把坐标回归权重调低0.5~1倍,让分类主导;数据增强里加随机遮挡,模拟旁边车辆进来后部分车位线被遮的情况。
4.4 confidence阈值一拉就崩:误检少了,漏检全出来了
现象:为压误检把score阈值从0.5拉到0.8,误检确实少了,但原来能检出的空车位也丢了一半。
原因:softmax出来的置信度不是真实概率。SSD对弱目标的分数本来就集中在0.4到0.7之间,阈值拉高等于把有效检测全部杀掉。
解决:不要手动拍阈值。用验证集做网格搜索,按F1值选最优score和NMS阈值;更稳的做法是不调高阈值,而是加一道独立判空手段,让它去做“这个框里是不是真有东西”的否决,检测器只负责召回。
4.5 推理端FPS不够:台式机转得快,盒子设备卡成PPT
现象:训练机GTX显卡跑得很顺,部署到Jetson盒子上只有3帧,停车场管理方直接不认。
原因:SSD的anchor解码和NMS在嵌入式设备上很慢,512输入加多个输出层,每帧要处理上万个候选框。
解决:部署输入用300或320;把anchor解码和NMS合并成向量化操作,或直接用TensorRT的聚核插件;只保留4~5个输出层参与推理,去掉靠近输入的大分辨率特征层,精度损失通常在1~2个mAP以内。先解决帧率,再回来补精度,反向顺序会浪费大量时间。
5. 部署到现场:推理脚本、NMS参数和车位编号映射
5.1 一个能直接用的最小推理脚本
训练完模型,验证集上效果看过了,还得换环境重新跑推理。基本流程是四步:预处理、前向、解码先验框、NMS合并重叠框。一个通用模板如下:
import cv2 import numpy as np import torch from ssd_model import build_ssd # 按你的源码结构导入模型构建函数 model = build_ssd(num_classes=2, size=300) ckpt = torch.load("checkpoints/ssd_best.pth", map_location="cpu") # 如果训练时用了DataParallel,权重名前有module前缀,去掉后再load if "model_state_dict" in ckpt: state = {k.replace("module.", ""): v for k, v in ckpt["model_state_dict"].items()} model.load_state_dict(state) model.eval() def infer(frame): rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) img = cv2.resize(rgb, (300, 300)).astype(np.float32) # 归一化:SSD常见均值与方差组合,按你的训练配置改 img = (img - np.array([123.0, 117.0, 104.0])) / np.array([1.0, 1.0, 1.0]) t = torch.from_numpy(img.transpose(2, 0, 1)).unsqueeze(0) with torch.no_grad(): confs, locs = model(t) # confs:(1, num_anchors, classes), locs:(1, num_anchors, 4) # decode: 把先验框+偏移还原成真实坐标,variance按训练配置给 boxes = decode(locs[0], priors, variance=(0.1, 0.2)) keep = nms(boxes, confs[0], score_thresh=0.5, nms_thresh=0.45, top_k=200) for idx in keep: x1, y1, x2, y2, score = boxes[idx] # 坐标是300x300下的,映射回原图分辨率 x1 = int(x1 * frame.shape[1] / 300) y1 = int(y1 * frame.shape[0] / 300) x2 = int(x2 * frame.shape[1] / 300) y2 = int(y2 * frame.shape[0] / 300) cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2) return frame注意几个决定性参数。variance=(0.1,0.2)是SSD标准配置,decode时坐标偏移要除以对应方差,改错了坐标全乱。score_thresh取0.5是折中值,具体用验证集网格搜索定。nms_thresh用0.45:两个车位框重叠超过45%会被合并,太小会让同一车位出多个框,太大又可能把相邻车位框吞掉。去掉module前缀这个操作无论谁都会踩一次,因为DataParallel太常用了。
5.2 从检测框中心点到车位编号:一次标定,长期复用
检测框本身只是像素坐标,业务上要的是“几号车位空着”。停车场相机机位固定,这个映射可以一次性标定完成。常见做法是:在图像里选四个已知车位角点,把对应的俯视平面坐标也量出来,用这些点对算出透视矩阵H,之后任意框的中心点乘H就能落到车位平面坐标,再去匹配编号表。
import numpy as np import cv2 # 源点:图像里四个车位角点;目标点:俯视平面图里对应坐标 src = np.array([[120, 340], [300, 332], [302, 390], [122, 402]], dtype=np.float32) dst = np.array([[0, 0], [250, 0], [250, 500], [0, 500]], dtype=np.float32) H = cv2.getPerspectiveTransform(src, dst) center = np.array([[[x, y]]], dtype=np.float32) plane = cv2.perspectiveTransform(center, H)[0][0]平面坐标再按车位编号表取模:车位宽度2.5m、深度5m,用取整除法就能算出落在第几行第几列。这里有个现场坑:H标定一次之后,相机如果被人为碰过哪怕几度,结果会整体偏移,所以最好在停车位边线上留一个能被程序检测的参考标志,每次启动时做一次小范围校正。没有校正机制的固定机位部署,两周后大概率全部偏码。
5.3 输出结构与去抖:连续几帧状态一致才认车位变更
识别结果要么写库,要么推给LED屏,通常用一个JSON结构就够了:
{ "slot_id": 17, "bbox_xyxy": [120, 340, 300, 500], "score": 0.87, "status": "free", "ts": 1711324800 }真正要费心思的是去抖逻辑。停车场里会有人走动、车头伸进车位一半又退出、照明闪烁,直接按单帧结果推状态,现场大屏会闪个不停。常见做法是维护每个slot_id的“变更确认计数”:只有当某个车位连续5~10帧(约2秒)都判定为同一种状态时,才对外推送变化。帧率低的时候计数要相应放宽,比如3 FPS下至少连续3帧,不然一辆车正常倒车入库会被当成“空→占→空→占”的几次跳动。
还有一个不大但很贴地的细节:输出坐标始终用原始图像分辨率,不要用模型输入尺寸。前端画框、后端计算面积都不关心模型内部像素,统一在外面还原,坑最少。
6. 进阶:给SSD加一道“纹理密度判空”冗余,把误检率再压一半
单靠SSD的置信度去判断“空不空”,在白天夜间切换时总有一段波动期。我试过的几个方案里,投入产出比最高的是在检测框内做一次纹理密度检查:空车位的特点是框内纹理极低,有车时车身、车窗会产生大量边缘。
def is_free_by_texture(roi, edge_thresh=0.03): gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY) edges = cv2.Canny(gray, 50, 150) density = (edges > 0).mean() return density < edge_thresh这个函数不用训练,对每个SSD检出的框crop出来跑一遍就行。边缘密度小于阈值的才放行,否则直接当成占用。阈值edge_thresh怎么定?取几百张验证集图片,画出空位和占用车位两个分布的直方图,在谷底取分界;更稳的是对0.01到0.1的区间做网格搜索,按F1选。我在现场遇到最典型的收益场景是:反光、阴影、白色车身导致SSD给出0.7分数的误检框,纹理密度直接把它拦下来。
验证方法也很省力:保留一份未参与训练的验证集,画出SSD单独判决和SSD加纹理判决的PR曲线对比。你会发现“检测器负责召回、判空负责查准”这个分工,比在同一模型中硬拉阈值更可控。做了这套双层判断之后,我最后悔的一件事就是初期把大量调参时间花在拉置信度上,而不是早一点加后置判空。
希望这个思路能帮你少走一段弯路,祝你的空车位识别顺利落地。
本文还有配套的精品资源,点击获取