十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

深度学习停车位视觉检测:从YOLO目标检测到CNN状态识别

深度学习停车位视觉检测:从YOLO目标检测到CNN状态识别 简介基于深度学习的停车位检测项目源码包面向计算机科学、电子信息工程、数学等专业方向的学生可作为课程设计、期末大作业或毕业设计的参考资料。项目聚焦城市停车位占用检测这一实际问题利用卷积神经网络从图像中自动识别车位状态以替代人工巡检或传统传感器方案在准确性、实时性与部署成本方面具备明显优势。压缩包内共26个文件主体为23个Python脚本覆盖数据准备、数据增强、TFRecord生成、模型定义、训练与测试、结果合并等完整流程另附配置说明、数据类别列表和README文档用于说明锚点参数、类别标签及运行方式。整个包体积仅42KB轻量且结构清晰。目前已有47人学习/下载。借助该项目读者可系统了解深度学习目标检测任务的落地过程包括如何制作数据集、设计训练脚本、调整模型参数以及输出检测结果。源码中保留了必要的注释和说明文档便于二次开发与功能扩展适合作为毕设或课设阶段的实践参考也可为后续深入研究打基础。1. 停车场视觉检测项目里藏着的两条技术线下班高峰期在商场地下车库绕三圈找不到空位这是城市里每天都在发生的场景。传统地磁传感器和超声波雷达成本高、维护难而且只能覆盖单个车位用摄像头做视觉检测则能一台设备覆盖整个车位区域。这套基于深度学习的停车位检测项目走的正是视觉方案它同时包含parking_context_recognizer和parking_slot_detector两个模块前者用 CNN 对车位上下文图像做占用分类后者用目标检测网络直接框出空车位位置。两个模块加上数据准备、训练、测试、结果合并脚本形成一条完整可跑的工程链路。适合正在选毕设方向、或者课设需要完整代码骨架的同学也适合想看看车位检测任务在数据组织和模型选型上有什么坑的工程师。2. 数据准备链路从标注框到 TFRecord 的关键步骤2.1 config.py 中需要关注的那几个核心参数项目根目录下的config.py是所有模块共用的配置入口。这个文件决定了数据路径、输入尺寸、训练超参数和锚框定义方式。我一般会把配置项按功能拆成几个区块数据相关、模型相关、训练相关、推理相关。下面是一个裁剪后的示例保留了你在正式跑代码时几乎一定会改的字段# config.py 示例裁剪注释后 import os # ---------- 数据路径 ---------- ROOT_DIR os.path.dirname(os.path.abspath(__file__)) DATA_DIR os.path.join(ROOT_DIR, data) TRAIN_IMAGES os.path.join(DATA_DIR, train_images) TRAIN_LABELS os.path.join(DATA_DIR, train_labels) VAL_IMAGES os.path.join(DATA_DIR, val_images) VAL_LABELS os.path.join(DATA_DIR, val_labels) # ---------- 模型输入 ---------- IMAGE_WIDTH 416 IMAGE_HEIGHT 416 CHANNELS 3 # ---------- 锚框可从 anchors.txt 读取后覆盖 ---------- ANCHORS_PATH os.path.join(DATA_DIR, anchors.txt) # ---------- 类别 ---------- CLASS_NAMES_PATH os.path.join(DATA_DIR, data.names) # ---------- 训练超参 ---------- BATCH_SIZE 16 EPOCHS 100 LEARNING_RATE 1e-3 LEARNING_RATE_DECAY 0.1 LEARNING_RATE_DECAY_EPOCHS [60, 90] # ---------- 损失权重 ---------- LOSS_COORD 5.0 LOSS_NOOBJ 0.5这里IMAGE_WIDTH和IMAGE_HEIGHT设为 416是 YOLO 系列常见的输入尺寸选择兼顾速度和精度。锚框路径指向anchors.txt类别名路径指向data.names这两个文件在数据准备阶段就会被反复读取。训练阶段调整LEARNING_RATE_DECAY_EPOCHS时要注意它表示第 60 轮和第 90 轮学习率各衰减一次衰减系数由LEARNING_RATE_DECAY控制。2.2 data.names 与 anchors.txt 的格式约定data.names每行一个类别名对停车位检测任务来说通常是类似下面这样的内容empty occupied注意区分大小写训练脚本解析时不会做归一化处理。anchors.txt的格式则是每行一组锚框宽高常见格式为10,14 23,27 37,58 81,82 135,169 344,319这 6 组锚框是 YOLOv3 在 COCO 数据集上聚类得到的结果不一定适配你的停车位数据分布。如果自己标注的数据集是俯拍视角、车位框长宽比相对固定建议用 k-means 重新聚类生成锚框方法是在训练样本的标注框上做 IoU 距离聚类替代原始尺寸距离。这个细节经常被忽略但影响收敛速度和最终 mAP。2.3 prepare_data.py 与 make_detector_data.py 的分工项目里有两个数据准备脚本名字接近但职责不同。prepare_data.py负责把原始图片和标注文件整理成统一的目录结构包括划分训练集、验证集并检查标注坐标是否越界。make_detector_data.py负责把整理后的数据转换为检测器真正能吃的格式。看文件命名可以推测这两步在原始项目中是这样衔接的先用prepare_data.py解析原始标注再调用make_detector_data.py生成最终标注和 TFRecord。如果自己接入新数据集建议保留这个双层结构——数据清洗和数据格式转换分开后期换标注工具时不用动转换逻辑。2.4 make_tfrecord.py 与 tf_dataset_utils.py 的序列化流程make_tfrecord.py把图片和标注打包为 TFRecord 文件tf_dataset_utils.py则在训练时负责解析。TFRecord 的核心是tf.train.Example协议缓冲把图像数据编码成字符串、标注框转成浮点数组后序列化。下面是一个可复用的参考写法# make_tfrecord.py 示例核心片段 import tensorflow as tf import numpy as np def _bytes_feature(value): 将字符串/字节数据包装为 Features。 if isinstance(value, type(tf.constant(0))): value value.numpy() return tf.train.Feature(bytes_listtf.train.BytesList(value[value])) def _float_feature(value): 将浮点列表包装为 Features。 return tf.train.Feature(float_listtf.train.FloatList(valuevalue)) def create_tf_example(image_path, boxes, labels): 构造一个 tf.train.Example 实例。 参数 image_path: 图片绝对路径 boxes: shape 为 (N, 4) 的归一化坐标数组格式为 [cx, cy, w, h] labels: shape 为 (N,) 的类别索引数组 image_data open(image_path, rb).read() feature { image/encoded: _bytes_feature(image_data), image/object/bbox: _float_feature(boxes.reshape(-1).tolist()), image/object/label: _float_feature(labels.astype(np.float32).tolist()), } return tf.train.Example(featurestf.train.Features(featurefeature))这段代码的关键在于坐标的归一化处理boxes传入前应先除以图片宽高得到相对坐标这样不同分辨率的图片可以混合训练。解析端对应使用tf.io.parse_single_example并结合tf.image.decode_jpeg解码。2.5 数据增强与标注同步变换的陷阱data_augmentation.py负责训练时的在线增强。常见的增强策略包括随机翻转、随机亮度对比度调整、随机缩放。用 TensorFlow 的tf.image完成常规增强很轻松但限制也很明显——对标注框的同步变换需要自己处理。下面的代码演示了随机水平翻转时如何同步变换归一化坐标# data_augmentation.py 示例核心片段 def random_flip_horizontal(image, boxes): 随机水平翻转图片与标注框。 参数 image: Tensorshape [H, W, C] boxes: Tensorshape [N, 4]归一化 [cx, cy, w, h] 返回 翻转后的图片和标注框如果未翻转则原样返回 if tf.random.uniform([]) 0.5: image tf.image.flip_left_right(image) # 中心点 x 坐标翻转cx - 1 - cx cx, cy, w, h tf.split(boxes, 4, axis-1) boxes tf.concat([1.0 - cx, cy, w, h], axis-1) return image, boxes翻转时容易踩的坑是只处理图片不处理标注框训练损失直接崩掉另一个坑是多人协作时有人按[x_min, y_min, x_max, y_max]格式存框有人按[cx, cy, w, h]存框。建议在数据准备脚本第一步就统一为[cx, cy, w, h]归一化格式并在make_tfrecord.py里加一个断言检查坐标取值范围。3. parking_context_recognizerCNN 上下文识别的实现思路3.1 模块定位为什么单独做一个上下文分类器parking_context_recognizer解决的是车位占用状态分类问题输入是单个车位区域的裁剪图像输出是该车位是否被占用。单独拆分这个模块的价值在于当目标检测模型输出的车位框不够稳定、或者需要判断车位内物体具体类型比如是车辆还是杂物时用小分类网络做二次确认会可靠很多。实际部署场景中很多方案会在检测器后面挂一个轻量分类器做精修本项目把这条思路做成了独立模块。3.2 models.py 的网络结构选型参考models.py中定义了上下文中使用的 CNN 结构。从项目文件命名看它属于中小型分类网络backbone 部分大概率是卷积堆叠结构。这里给一个可直接替换的参考实现用标准的卷积、池化、全连接三层结构实现二分类# models.py 示例基于 TensorFlow/Keras 的分类网络 import tensorflow as tf from tensorflow.keras import layers, Model def build_context_classifier(input_shape(64, 64, 3), num_classes2): 构建一个轻量车位上下文分类器。 结构3 组卷积 池化展平后接全连接层输出为 softmax 概率。 inputs tf.keras.Input(shapeinput_shape) x layers.Conv2D(32, (3, 3), activationrelu, paddingsame)(inputs) x layers.MaxPooling2D((2, 2))(x) x layers.Conv2D(64, (3, 3), activationrelu, paddingsame)(x) x layers.MaxPooling2D((2, 2))(x) x layers.Conv2D(128, (3, 3), activationrelu, paddingsame)(x) x layers.GlobalAveragePooling2D()(x) x layers.Dense(64, activationrelu)(x) outputs layers.Dense(num_classes, activationsoftmax)(x) return Model(inputs, outputs) # 实例化并查看参数量 model build_context_classifier() model.summary()这里用GlobalAveragePooling2D替代了传统的Flatten好处是大幅减少全连接层参数量降低过拟合风险。输入尺寸设为 64×64 是因为车位裁剪图一般不需要太大分辨率保留足够的纹理信息即可。如果你的摄像头分辨率高、车位区域大可以适当提升到 96 或 128但要接受推理时间增加。3.3 train.py 训练循环里容易被忽略的细节train.py是上下文分类器的训练入口。它负责读取 TFRecord、解析图像和标签、批量喂入模型、计算损失并更新权重。二分类任务推荐用categorical_crossentropy配合softmax输出或者用binary_crossentropy配合sigmoid输出。前者更适合多类扩展后者收敛略快。这里给出关键段落的写法# train.py 示例上下文分类器训练循环关键片段 def train_one_epoch(model, dataset, loss_fn, optimizer, metric): 训练一个 epoch遍历全部 batch。 for batch_images, batch_labels in dataset: with tf.GradientTape() as tape: logits model(batch_images, trainingTrue) loss loss_fn(batch_labels, logits) grads tape.gradient(loss, model.trainable_variables) optimizer.apply_gradients(zip(grads, model.trainable_variables)) metric.update_state(batch_labels, logits) return loss.numpy(), metric.result().numpy()训练时要留意类别不平衡问题。停车位数据集中occupied和empty的比例经常偏差很大比如地下车库大部分车位是空的。处理办法是设置类别权重在loss_fn中传入class_weight参数或者对训练集做降采样。另外一个常见问题是验证集划分不合理——同一个停车场连续帧的图像高度相似导致验证指标虚高建议按时间或按摄像头位置划分训练集和验证集而不是随机切分。3.4 merge_three_type_result_files.py三分类结果合并的工程智慧这个脚本的名字很有意思merge_three_type_result_files表明检测结果被分成了三类。常见的设计是empty空车位、occupied被占用、unknown不确定。后处理时把单帧检测结果、多帧投票结果、分类器置信度结果合并得出最终结论。# merge_three_type_result_files.py 示例结果合并逻辑 def merge_results(detection_file, classification_file, output_file, threshold0.6): 合并检测结果与分类结果。 策略 1. 分类器置信度高于 threshold 时直接采用分类结果 2. 低于 threshold 时沿用检测器的输出 3. 检测器与分类器结论冲突时标记为 unknown。 results {} with open(detection_file, r) as f_det, open(classification_file, r) as f_cls: for det_line, cls_line in zip(f_det, f_cls): det_label det_line.strip().split(,)[0] cls_label cls_line.strip().split(,)[0] cls_conf float(cls_line.strip().split(,)[1]) if cls_conf threshold: final_label cls_label elif det_label ! cls_label: final_label unknown else: final_label det_label results[det_line.split(,)[-1]] final_label # 写出合并结果 with open(output_file, w) as f_out: for slot_id, label in results.items(): f_out.write(f{slot_id},{label}\n)三种标签的设计在实际部署中很有用当画面被车辆遮挡、光照剧烈变化时模型输出置信度低这时标记为unknown好过强行给出empty或occupied的错误结果。调度系统收到unknown时可以暂时不更新车位状态等待下一帧再做判断。这种带不确定性的输出设计是教科书里很少提到但工程上非常实用的思路。4. parking_slot_detectorYOLO 思路下的车位框检测4.1 detector 模块的输入输出设计与 anchors 使用方式parking_slot_detector走的是目标检测路线输入整张停车场照片输出所有车位框的位置和类别。由于车位框和车辆框在视觉特征上差异较大实际项目中这个模块往往负责找车位线框而车辆占用判断交给上下文分类器完成这就是前文merge_three_type_result_files.py的由来。utils/model.py中封装了检测网络的前向推理逻辑包括锚框解码、置信度过滤、非极大值抑制NMS三个步骤。锚框是检测网络预测的基准训练时模型学习的是相对锚框的偏移量。读取锚框的代码一般在config.py或utils/model.py里出现。# utils/model.py 示例锚框加载与基准网格生成 def load_anchors(anchors_path): 从 anchors.txt 读取锚框每行格式为 w,h。 anchors [] with open(anchors_path, r) as f: for line in f: w, h line.strip().split(,) anchors.append([float(w), float(h)]) return np.array(anchors) def generate_grid(scale, num_anchors): 生成特征图上每个格子的基准坐标。 grid_x, grid_y np.meshgrid(np.arange(scale), np.arange(scale)) grid np.stack([grid_x, grid_y], axis-1) # shape [scale, scale, 2] return np.tile(grid, (1, 1, num_anchors, 1))锚框数量与特征图尺度有关。YOLOv3 使用三个不同尺度的特征图每个尺度分配 3 个锚框共 9 个。本项目anchors.txt有 6 组锚框说明网络可能只有两个检测尺度或者对锚框做了自定义精简适配停车位场景后减少了锚框数量。训练时如果发现召回率偏低优先检查锚框是否覆盖了小目标——远处车位的框可能只有几十个像素宽。4.2 train.py 中损失函数与收敛判断检测器的train.py比上下文分类器的训练逻辑复杂得多。损失函数通常包含三部分坐标回归损失衡量预测框与真实框位置偏差、置信度损失衡量是否有目标的判断是否正确、分类损失衡量类别预测是否正确。YOLO 系列的通用做法是对坐标和置信度使用均方误差对分类使用交叉熵。训练时要盯的指标除了总损失还有每个分项的损失曲线损失项含义正常收敛趋势异常情况坐标损失预测框与标注框的位置偏差持续下降后期趋于平稳震荡不降说明学习率过大置信度损失是否有车位的判断误差快速下降后缓慢降低居高不下可能是正负样本不平衡分类损失车位类别判断误差稳定下降反弹说明过拟合开始如果训练时发现总损失下降但 mAP 不涨优先看坐标损失是否已经收敛而置信度损失还在震荡这种时候单独调整LOSS_COORD和LOSS_NOOBJ的权重比盲目调学习率有效。4.3 test.py 与实际推理流程test.py是推理验证入口。它的核心逻辑是读取测试图片、前向推理、解码预测结果、执行 NMS、计算 mAP 并输出可视化结果。推理阶段与训练阶段最大的区别是不计算梯度、不做数据增强、预测结果需要后处理才能使用。解码阶段常见的问题是把特征图上的预测值误当作最终坐标。实际预测的是偏移量需要结合网格坐标和锚框尺寸换算。下面是一个标准的解码流程# test.py 示例预测框解码 def decode_predictions(feature_map, anchors, grid_size): 将网络输出解码为实际的框坐标与置信度。 # feature_map 形状: [batch, grid, grid, num_anchors * (5 num_classes)] batch_size feature_map.shape[0] feature_map tf.reshape(feature_map, (batch_size, grid_size, grid_size, len(anchors), -1)) box_xy, box_wh, confidence, class_probs tf.split( feature_map, [2, 2, 1, -1], axis-1) # sigmoid 将坐标偏移与置信度映射到 [0, 1] box_xy tf.sigmoid(box_xy) confidence tf.sigmoid(confidence) class_probs tf.sigmoid(class_probs) # 结合网格坐标与锚框尺寸 grid tf.meshgrid(tf.range(grid_size), tf.range(grid_size)) grid tf.stack(grid, axis-1)[tf.newaxis, ..., tf.newaxis, :] box_xy (box_xy grid) / grid_size box_wh tf.exp(box_wh) * anchors / grid_size return box_xy, box_wh, confidence, class_probs这段代码中box_xy是中心点坐标除以grid_size的目的是归一化到 [0, 1] 区间box_wh通过指数运算保证宽高为正再乘以锚框尺寸。解码后还要做置信度过滤一般取阈值 0.25 或 0.5这个值在config.py或test.py开头定义实际调优时可以对不同类别设置不同阈值。4.4 上下文分类与检测器如何配合使用两个模块配合使用的完整链路是检测器在整图上框出所有候选车位位置和时间戳然后按框裁剪出局部图像送入上下文分类器判断empty/occupied/unknown最后按时间戳对齐合并输出。这样设计的好处是检测器只需要定位车位不需要精确分类——车位定位是回归问题占用判断是分类问题两者用不同模型各司其职训练难度和调优方向都更清晰。5. 推理加速与部署从测试脚本到真实停车场的最后一公里5.1 用 TensorRT 量化加速的小技巧毕设或者课设阶段训练出的模型通常在 GPU 上运行即可但一旦要部署到停车场出入口的 Jetson 设备或者普通 CPU 工控机上推理速度就得认真对待。常见做法是把训练好的模型导出为SavedModel或ONNX格式再通过 TensorRT 做 FP16 精度推理。以 TensorFlow 模型为例导出和转换的核心命令如下# 导出 SavedModel python export_model.py --checkpoint ./checkpoints/model_epoch_100 \ --output ./exported_model # 转换为 ONNX需要 tf2onnx 工具 python -m tf2onnx.convert \ --saved-model ./exported_model \ --output ./model.onnx \ --opset 11 # 用 trtexec 做 FP16 转换TensorRT 自带工具 trtexec --onnx./model.onnx \ --saveEngine./model_fp16.engine \ --fp16转换过程中最容易踩的坑是自定义层不支持 ONNX 算子集。项目中如果用了自定义的损失函数或后处理逻辑导出时应把后处理剥离出计算图只保留主干推理部分后处理在部署端用原生代码实现。5.2 一个可直接复用的推理脚本骨架把检测和分类合在一起的完整推理流程推荐写成独立脚本infer_one_image.py代码如下# infer_one_image.py 示例 import cv2 import numpy as np import tensorflow as tf from utils.model import decode_predictions, non_max_suppression from models import build_context_classifier def infer(image_path, detector, classifier, anchors, conf_thresh0.5): 对单张停车场图片执行完整推理。 # 读图并预处理到检测器输入尺寸 image cv2.imread(image_path) orig_h, orig_w image.shape[:2] resized cv2.resize(image, (416, 416)) / 255.0 input_tensor tf.convert_to_tensor(resized[np.newaxis, ...], dtypetf.float32) # 检测器前向推理 feature_maps detector(input_tensor, trainingFalse) # 解码 NMS得到 [x1, y1, x2, y2, score, class] 的列表 boxes decode_and_filter(feature_maps, anchors, conf_thresh) boxes non_max_suppression(boxes, iou_threshold0.45) # 对每个检测框裁剪并送分类器 results [] for box in boxes: x1, y1, x2, y2 scale_box(box, orig_w, orig_h) crop image[y1:y2, x1:x2] crop cv2.resize(crop, (64, 64)) / 255.0 crop_tensor tf.convert_to_tensor(crop[np.newaxis, ...], dtypetf.float32) prob classifier(crop_tensor, trainingFalse).numpy()[0] results.append((box, prob)) return image, results这个脚本演示了两个模型串联的典型写法。实际部署时建议把detector和classifier都导出为签名固定的函数避免每次推理都重新构图。另外注意scale_box将归一化坐标还原为原图尺寸时有坐标越界风险裁剪前要做clip操作防止y2或x2超出图像边界导致cv2报错。5.3 多帧结果平滑与状态上报单帧推理结果直接上报会产生大量抖动一辆车缓慢驶过前一帧车位还是空、后一帧就是占用。工程上通常做多帧投票比如连续 5 帧中 3 帧判定为占用才改变状态。这个方法在项目代码里体现为连续帧结果文件的合并与阈值过滤实现起来很轻量def smooth_states(history, window_size5, majority3): 对连续帧的停车位状态做多数投票平滑。 final_states {} for slot_id, states in history.items(): recent states[-window_size:] if recent.count(occupied) majority: final_states[slot_id] occupied elif recent.count(empty) majority: final_states[slot_id] empty else: final_states[slot_id] unknown return final_states多帧投票的参数选择取决于摄像头帧率。25 帧/秒的摄像头下5 帧窗口相当于 200 毫秒内做一次决策既能过滤单帧误检又不会让响应慢到用户察觉。窗口大小和多数阈值建议单独放到配置文件中方便现场调试时不改代码直接调参数。本文还有配套的精品资源点击获取
返回列表