拓冰建站拓冰建站
首页 / 资讯中心 / 正文

基于SSD的空车位识别与余位统计实战:从数据标注到边缘部署

简介这份资源是基于Python的SSD空停车位识别演示项目源码面向深度学习入门者、目标检测学习者以及智能停车场系统开发者用于解决实际场景中空车位自动检测与定位的问题。项目以SSDSingle Shot MultiBox Detector算法为核心覆盖数据预处理、模型训练、结果评估与客户端-服务器部署等完整流程适合作为深度学习落地智能交通领域的实践范例。压缩包共78个文件约282KB其中61个py源码文件承载算法与业务逻辑5个xml与1个yaml负责模型参数和网络结构配置另有txt说明、gitignore、iml等辅助文件目录按configs、ssd、data、engine、modeling等模块清晰划分。目前已有314人学习下载。通过阅读源码读者可掌握SSD网络结构搭建、锚框设计、数据增强与推理部署等关键环节并借鉴demo、server、client等脚本理解实时检测的客户端-服务器架构为智能停车场管理系统的二次开发提供可复用的参考实现。1. 从一张停车场俯视图说起SSD 做空车位识别到底难在哪地下停车场入口那块 LED 屏上跳动的剩余车位数背后往往不是地磁传感器而是一路摄像头加一个目标检测模型。基于 Python 的 SSD-Demo 空停车位识别与识别算法设计源码讲的正是这条链路用 SSDSingle Shot MultiBox Detector在俯拍或斜拍的停车场画面里框出空车位再把框的数量汇总成可展示的余位数据。它适合两类人一类是刚学完 python 入门、想找一个能跑通的目标检测练手项目的同学另一类是在做智慧停车、园区管理需要快速验证视觉方案可行性的工程师。SSD 目标检测相比两阶段方法单次前向就能出框推理速度对实时视频流友好这是它被选中的核心理由。但空车位识别有它自己的坑车位线被压、相邻车位粘连、光照突变、遮挡都会让模型把「有车」判成「空」。这篇笔记就按「数据怎么标、模型怎么搭、训练怎么调、推理怎么接、坑怎么避」的顺序把一套能复现的方案讲清楚。2. 空车位识别为什么选 SSD原理、选型与数据准备2.1 SSD 的单阶段检测逻辑与空车位场景的匹配度SSD 的核心思路是在不同尺度的特征图上铺密集的先验框prior box每个先验框直接预测类别分数和边界偏移最后用 NMS 合并。它没有 RPN 那样的候选区域生成阶段所以速度上有天然优势。放到空车位识别里这个特性很关键停车场视频通常要 15 到 25 FPS 实时处理两阶段模型在边缘设备上容易掉帧而 SSD300 在普通 GPU 上跑几百 FPS 是常见水平。空车位本质上是一个「固定位置、固定形状」的目标。车位在地面上不会动尺寸随摄像头安装高度和角度确定长宽比大多在 2:1 到 2.5:1 之间。这意味着先验框的尺度和长宽比可以针对性地调不需要像通用检测那样铺得很宽。我一般会把 aspect ratio 设成 [1.5, 2.0, 2.5]而不是默认的 [1, 2, 3]实测召回更稳。另一个匹配点是类别少。空车位识别通常只有两类空车位、占用车位或者只检测空车位一类。类别少意味着分类头压力小模型容量可以适当收训练也更容易收敛。但要注意类别少不代表简单因为「空」和「占用」的视觉差异有时只在于地面有没有一块深色区域模型很容易被阴影骗。2.2 数据集标注VOC 格式与车位框的四个边界坑数据是这套方案里最花时间的部分。常见做法是用 labelImg 按 VOC 格式标每张图对应一个 XML里面记录每个车位框的 xmin、ymin、xmax、ymax 和类别名。车位框的标注有几个容易翻车的地方。第一框要贴车位线内沿还是外沿。如果框到线外相邻两个空车位会重叠NMS 一合并就少一个。我一般要求标注框贴车位线内侧留 2 到 3 像素余量。第二斜拍视角下车位是梯形但 VOC 只支持矩形框。这时候框要覆盖车位可停放区域的最大内接矩形不要为了包住整个梯形把框拉大。第三被部分遮挡的车位怎么标。如果遮挡超过 50%建议标成 difficult 或者直接不标否则模型会学到错误的边界。第四类别命名要统一。有的标empty有的标free混在一起训练时类别映射会乱。建议固定用space空位和occupied占用两个类名。标注完成后目录结构建议这样组织dataset/ ├── annotations/ # 所有 XML 文件 ├── images/ # 所有 JPG 原图 ├── ImageSets/ │ └── Main/ │ ├── train.txt # 训练集文件名列表不带扩展名 │ ├── val.txt │ └── test.txt └── labels/ # 转换后的 YOLO 格式可选train.txt 里每行是一个文件名比如parking_001不带.jpg。这个细节很多新手会写错导致训练时找不到图。2.3 从 VOC 到 SSD 输入数据增强与先验框配置SSD 训练时输入是固定尺寸常见 300x300 或 512x512。停车场图片通常是宽幅直接 resize 会压扁车位比例。我一般用 letterbox 方式保持长宽比缩放短边补灰边。这样车位长宽比不会失真先验框的 aspect ratio 设置才有意义。数据增强方面颜色抖动亮度、对比度、饱和度是必须的因为停车场不同区域光照差异大。随机裁剪要慎用裁掉一半车位会让标注框变得不完整。水平翻转可以用但要注意如果摄像头有固定方向性比如只能从某一侧进入翻转后的样本可能不符合真实分布。先验框的配置在ssd300_config.py这类文件里关键参数是min_sizes、max_sizes和aspect_ratios。以 300 输入为例常见配置是# SSD300 先验框配置片段 prior_config { feature_maps: [38, 19, 10, 5, 3, 1], # 六个特征图尺度 min_sizes: [30, 60, 111, 162, 213, 264], max_sizes: [60, 111, 162, 213, 264, 315], aspect_ratios: [[1.5, 2.0], [1.5, 2.0, 2.5], [1.5, 2.0, 2.5], [1.5, 2.0, 2.5], [1.5, 2.0], [1.5, 2.0]], variance: [0.1, 0.2], clip: True, }feature_maps是各层输出特征图的尺寸min_sizes和max_sizes控制先验框的绝对大小aspect_ratios控制长宽比。空车位场景里车位在画面中的像素宽度大概在 60 到 200 之间所以 min_sizes 的前几层要覆盖这个范围。如果车位普遍偏小可以把前两层的 min_sizes 调低比如 24 和 48。提示先验框配置改完后一定要用可视化脚本把框画到一张样例图上确认框的密度和尺度覆盖了真实车位不要只看 loss 曲线。3. 用 PyTorch 搭 SSD 空车位检测模型、训练与评估3.1 骨干网络选择与 SSD 头部的 Python 实现SSD 的骨干网络常见有 VGG16 和 ResNet。VGG16 是原版 SSD 的配置结构简单适合练手ResNet 收敛更快但需要额外处理特征层抽取。如果只是做 Demo 验证我建议先用 VGG16 版本因为网上可参考的配置多出问题好排查。下面是一个简化版的 SSD 头部实现展示多尺度特征提取和检测头的基本结构import torch import torch.nn as nn class SSDHead(nn.Module): def __init__(self, num_classes, num_priors): super().__init__() # 每个先验框预测 num_classes 个类别分数 4 个偏移量 self.num_classes num_classes self.num_priors num_priors self.loc nn.Conv2d(512, num_priors * 4, kernel_size3, padding1) self.conf nn.Conv2d(512, num_priors * num_classes, kernel_size3, padding1) def forward(self, features): locs, confs [], [] for feat in features: locs.append(self.loc(feat).permute(0, 2, 3, 1).contiguous()) confs.append(self.conf(feat).permute(0, 2, 3, 1).contiguous()) # 展平后拼接形状 [batch, total_priors, 4] 和 [batch, total_priors, num_classes] locs torch.cat([o.view(o.size(0), -1) for o in locs], dim1) confs torch.cat([o.view(o.size(0), -1) for o in confs], dim1) return locs.view(locs.size(0), -1, 4), confs.view(confs.size(0), -1, self.num_classes)loc和conf是两个 3x3 卷积分别输出边界偏移和类别分数。permute把通道维换到最后方便后续展平。num_priors是每个特征点对应的先验框数量它由 aspect_ratios 的数量决定。如果 aspect_ratios 设了 [1.5, 2.0, 2.5]加上一个 1:1 的框每个点就是 4 个先验框。这个数字要和先验框生成逻辑严格对齐否则训练时框和预测对不上loss 会直接 NaN。3.2 训练脚本的关键参数学习率、batch size 与匹配策略训练 SSD 有几个参数直接决定能不能收敛。学习率初始值一般设 1e-3用 SGD 加 momentum 0.9weight decay 5e-4。如果 loss 在前几百步就炸了先把学习率降到 1e-4 试试。batch size 受显存限制300 输入下 16 或 32 都常见太小会让 BN 层统计不稳。匹配策略是 SSD 训练的核心每个真实框要和先验框做匹配匹配上的算正样本没匹配上的算负样本。正样本太少会导致正负失衡所以 SSD 用了 hard negative mining按置信度 loss 排序取一定比例的负样本参与训练。这个比例一般是 3:1也就是负样本最多是正样本的三倍。# 训练循环中的关键片段 model.train() for images, targets in train_loader: images images.to(device) targets [t.to(device) for t in targets] optimizer.zero_grad() loc_pred, conf_pred model(images) # 匹配先验框与真实框 loc_t, conf_t match_priors(targets, priors, threshold0.5) # 计算损失定位用 smooth L1分类用交叉熵 loss_l smooth_l1_loss(loc_pred, loc_t) loss_c cross_entropy_loss(conf_pred, conf_t) # hard negative mining 后分类损失只取部分负样本 loss loss_l loss_c loss.backward() optimizer.step()threshold0.5是 IoU 匹配阈值高于它的先验框算正样本。这个值不要调太低否则会把大量背景框当成车位模型学到的框会飘。smooth_l1_loss对异常值不敏感适合边界回归。分类损失里hard negative mining 的实现要注意先算所有负样本的 loss排序后取前 N 个N 由正样本数量乘以 3 决定。3.3 评估指标mAP 之外还要看车位级别的漏检和误检mAP 是目标检测的通用指标但空车位识别里mAP 高不代表业务可用。因为车位是固定位置一个车位漏检意味着余位数少 1一个误检意味着余位数多 1。所以除了 mAP还要看两个业务指标漏检率真实空位没被检出的比例和误检率占用车位被检成空位的比例。评估时建议按车位位置做匹配而不是按框的 IoU。因为同一个车位模型框大一点小一点IoU 可能只有 0.4但业务上它是对的。可以设一个中心点距离阈值比如预测框中心落在真实框中心 20 像素内就算匹配。这样评估结果更贴近实际余位统计。# 按中心点距离匹配的评估逻辑 def evaluate_by_center(pred_boxes, gt_boxes, dist_thresh20): matched_gt set() tp 0 for pb in pred_boxes: pcx (pb[0] pb[2]) / 2 pcy (pb[1] pb[3]) / 2 for i, gb in enumerate(gt_boxes): if i in matched_gt: continue gcx (gb[0] gb[2]) / 2 gcy (gb[1] gb[3]) / 2 if ((pcx - gcx) ** 2 (pcy - gcy) ** 2) ** 0.5 dist_thresh: tp 1 matched_gt.add(i) break fp len(pred_boxes) - tp fn len(gt_boxes) - len(matched_gt) return tp, fp, fndist_thresh根据摄像头分辨率和车位像素尺寸调1080P 画面里车位宽度大概 100 到 150 像素20 像素阈值比较合理。这个评估方式不依赖框的精确回归更适合固定车位场景。4. 推理部署与余位统计从模型输出到 LED 屏数字4.1 单张图片推理预处理、前向与 NMS 后处理推理流程分四步读图、预处理、前向、后处理。预处理要和训练时一致letterbox 缩放加归一化。归一化参数用 ImageNet 的 mean 和 std 就行因为骨干网络是在 ImageNet 上预训练的。import cv2 import numpy as np import torch def preprocess(image_path, size300): img cv2.imread(image_path) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) h, w img.shape[:2] scale min(size / h, size / w) nh, nw int(h * scale), int(w * scale) resized cv2.resize(img, (nw, nh)) canvas np.full((size, size, 3), 114, dtypenp.uint8) canvas[:nh, :nw] resized tensor torch.from_numpy(canvas).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) tensor (tensor - mean) / std return tensor.unsqueeze(0), scale, (h, w)114是补边灰度值和 SSD 原版一致。scale和原始尺寸要留着后处理时把框映射回原图坐标。归一化用 ImageNet 统计量如果骨干网络换成了别的预训练权重这里要跟着换。后处理主要是 NMS。SSD 输出的框很多同一车位会有多个高分框NMS 按 IoU 阈值 0.45 合并。空车位场景里相邻车位框可能挨得近IoU 阈值不要设太低否则会把两个相邻空位合并成一个。我一般用 0.45如果发现相邻车位被吞调到 0.5 或 0.55。4.2 视频流推理与余位统计的工程化处理视频流推理和单图差不多区别在于要维护一个稳定的计数。因为视频帧之间有抖动某一帧多检一个或少检一个LED 数字就会跳。常见做法是加一个滑动窗口投票取最近 10 帧的检测数量取中位数作为当前余位。from collections import deque class ParkingCounter: def __init__(self, window_size10): self.window deque(maxlenwindow_size) def update(self, count): self.window.append(count) # 取中位数避免单帧抖动 sorted_w sorted(self.window) mid len(sorted_w) // 2 if len(sorted_w) % 2 0: return (sorted_w[mid - 1] sorted_w[mid]) / 2 return sorted_w[mid]window_size根据帧率调25 FPS 下 10 帧是 0.4 秒响应够快也能滤抖。如果场景里车辆进出频繁窗口可以缩短到 5 帧。另外可以加一个「车位区域掩码」只统计停车场区域内的框把画面边缘的误检滤掉。4.3 模型导出与边缘设备部署的注意点如果要在 RK3588S 这类边缘设备上跑模型需要先导出成 ONNX再转成 RKNN。导出 ONNX 时注意把 NMS 放到模型外面因为 RKNN 对 NMS 的支持有限。另外SSD 的 permute 和 view 操作在导出时可能被优化掉导致输出形状和预期不一致导出后要用 onnxruntime 跑一遍对比结果。部署时输入尺寸可以降到 300 甚至 256换取更高帧率。空车位识别对框的精度要求没有通用检测那么高因为车位位置固定稍微偏一点不影响计数。如果帧率还是不够可以隔帧推理中间帧用上一帧结果配合滑动窗口也能稳住计数。5. 避坑与排查空车位识别训练和推理中的五个血泪教训5.1 现象loss 一直不降分类 loss 在 0.69 附近震荡原因类别映射错了。VOC 标注里类别名和代码里的classes列表顺序不一致导致模型把空车位学成了背景。或者num_classes设成了 2但实际标注里有 3 个类名包括背景分类头输出维度对不上。解决打印dataset.classes和标注 XML 里的name字段确认完全一致。num_classes要包含背景类通常是实际类别数加 1。如果用的是只检测空车位的方案类别数就是 2背景 空位。5.2 现象训练 loss 正常下降但推理时框全挤在画面左上角原因先验框的variance和编码解码不匹配。SSD 的边界回归输出的是偏移量解码时要乘以 variance 再乘先验框宽高。如果训练时用了 variance推理时忘了乘框就会缩在一起。解决检查训练和推理的编解码函数是否用同一套 variance。常见配置是variance[0.1, 0.2]中心偏移乘 0.1宽高偏移乘 0.2。这个参数在训练脚本和推理脚本里必须一致。5.3 现象相邻两个空车位只检出一个框原因NMS 的 IoU 阈值太低把两个相邻框合并了。或者标注时两个车位框重叠太多训练时匹配到了同一个先验框。解决先把 NMS 阈值从 0.45 调到 0.55 试试。如果还不行检查标注框是否贴了车位线外沿把框改到内沿。另外可以在先验框配置里增加 aspect_ratio 2.5 的框让模型更容易区分细长车位。5.4 现象白天正常傍晚或地下车库灯光变化时漏检严重原因训练集里光照样本不均衡模型过拟合到了某种亮度。颜色抖动增强的幅度不够或者根本没开。解决把颜色抖动的亮度范围从 ±20 扩大到 ±40对比度从 ±0.2 扩大到 ±0.4。如果条件允许在傍晚和夜间各采一批图加入训练集。另外可以在推理前做一次直方图均衡化但要注意这可能会改变训练和推理的分布一致性最好在训练时也加同样的预处理。5.5 现象模型在 PC 上跑正常转 ONNX 后结果全乱原因导出时没有把模型设为 eval 模式BN 层和 dropout 还在训练状态。或者输入尺寸写死成了动态维度ONNX 推理时 shape 对不上。解决导出前加model.eval()并用torch.no_grad()包住。输入用torch.onnx.export时指定input_names和output_names动态轴只保留 batch 维。导出后用 onnxruntime 跑同一张图和 PyTorch 输出对比误差应该在 1e-4 以内。6. 把余位统计做稳的一个小技巧车位区域掩码加时序平滑最后一章说一个我在实际项目里反复用到的技巧车位区域掩码加时序平滑。空车位识别模型再准也会在画面边缘、通道区域产生误检。这些误检如果直接进计数LED 数字就会偶尔跳一下用户看到会觉得系统不稳。做法很简单在标注阶段除了标车位框再画一个停车场区域的多边形掩码存成 JSON 或 npy。推理时只保留中心点落在掩码内的检测框。这一步能把通道、绿化带、墙面上的误检直接滤掉通常能减少 30% 到 50% 的误检。import numpy as np import cv2 def load_mask(mask_path, shape): # mask_path 是二值 PNG白色为停车场区域 mask cv2.imread(mask_path, 0) mask cv2.resize(mask, (shape[1], shape[0])) return mask 128 def filter_by_mask(boxes, mask): kept [] for box in boxes: cx int((box[0] box[2]) / 2) cy int((box[1] box[3]) / 2) if 0 cy mask.shape[0] and 0 cx mask.shape[1]: if mask[cy, cx]: kept.append(box) return keptmask用标注工具画多边形后导出二值图白色区域是有效停车场。filter_by_mask只保留中心点在掩码内的框。这个操作在 NMS 之后做计算量很小。时序平滑就是前面说的滑动窗口中位数。两个结合起来余位数字基本不会因为单帧误检跳动。如果场景里车辆进出频繁可以把窗口从 10 帧缩到 5 帧响应更快。另外可以加一个变化率限制余位数字每秒最多变化 2超过就保持上一秒的值等下一帧确认。这个在车辆快速进出时能避免数字闪跳。我自己的习惯是每次换摄像头机位第一件事不是调模型而是重新画掩码。掩码画对了后面调参省一半力气。模型版本迭代时掩码不用动只换权重就行。这套 SSD-Demo 的空车位识别方案核心不在模型多复杂而在数据标注的一致性、先验框的针对性配置、以及推理端的工程化平滑。把这三点做扎实余位统计的准确率能稳定在 95% 以上。希望帮到你。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门