
简介这是一个基于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转tensorHWC - 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, dtypetorch.float32), torch.tensor(labels)代码的逻辑是从txt读图片清单按id找到jpg和xml解析出bounding box和标签然后统一resize到固定尺寸。注意坐标必须从原图尺寸等比缩放到模型输入尺寸否则回归头学到的是错位的偏移permute(2,0,1)是把PIL的HWC布局转成Pytorch的CHWfloat()是为了满足卷积输入类型。这个Dataset里刻意只保留“free_parking_space”这个正类背景类在SSD内部由先验框自动生成不需要显式标注。如果你要改成路线B检测所有车位再判空只需要把类别名换成“parking_slot”并在模型配置里把类别数从2改成3其他结构不变。这是SSD-Demo最容易扩展的位置。3.3 训练参数表把参考值抄下来再按显存微调训练参数是最容易抄作业的部分也最容易一眼翻车。以下是我的常用配置先照抄再微调参数参考值说明骨干网络VGG16ImageNet预训练SSD-Demo默认结构换ResNet要改对应层的anchor分配输入尺寸300×300 或 512×512300快、512准后面部署按设备选batch size16300输入/ 8512输入显存不够先减半别硬撑优化器SGDmomentum0.9weight_decay5e-4Adam也能训SGD的最终精度更可控初始学习率0.001带warmup迭代2000步后线性升到0.01比固定0.01稳学习率衰减40/60 epoch各降10倍或cosine退火二选一即可正样本匹配IoU0.45~0.5空车位先验框匹配率低0.5不行就降负正样本比3:1hard negative mining按loss取top样本数据增强随机裁剪、颜色抖动、水平翻转加一块随机黑斑模拟遮挡更贴近现场总epoch50~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_classes2, size300) ckpt torch.load(checkpoints/ssd_best.pth, map_locationcpu) # 如果训练时用了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_thresh0.5, nms_thresh0.45, top_k200) 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]], dtypenp.float32) dst np.array([[0, 0], [250, 0], [250, 500], [0, 500]], dtypenp.float32) H cv2.getPerspectiveTransform(src, dst) center np.array([[[x, y]]], dtypenp.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_thresh0.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曲线对比。你会发现“检测器负责召回、判空负责查准”这个分工比在同一模型中硬拉阈值更可控。做了这套双层判断之后我最后悔的一件事就是初期把大量调参时间花在拉置信度上而不是早一点加后置判空。希望这个思路能帮你少走一段弯路祝你的空车位识别顺利落地。本文还有配套的精品资源点击获取