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

YOLOv5车辆检测与计数:从IOU跟踪到检测线统计

简介这是基于YOLOv5的车辆目标检测算法包面向计算机视觉方向的初、中级学习者和开发者主要解决视频流中车辆实时识别、跟踪与过车计数的问题。包内已实现逐帧车辆检测、目标框选与类别标注能通过检测线区域自动统计通过的车辆总数并在图形界面中同步显示当前帧速率同时提供车辆位置信息的获取与打印输出方便记录与调试。资源共11个文件以3个Python脚本为核心分别承担主检测逻辑、视频时间读取和绿灯时间计算等任务6个XML文件用于模型或界面参数配置另有项目模块描述与Git忽略规则等工程辅助文件整体压缩包仅52KB结构紧凑便于快速定位核心代码并迁移到自己的项目中。当前已有5210人学习适合希望直接参考完整车辆检测与跟踪流程、理解YOLOv5工程化调用方式或在此基础上继续扩展车速统计、违章抓拍等功能的读者。1. 交通视频里的车辆检测从 YOLOv5 到计数线很多人拿到一段路口监控视频第一反应是“用 OpenCV 差帧法或者背景建模把车抠出来”但真正到了光照变化、车辆遮挡、夜间反光的时候传统算法会漏检到没法看。这个项目把 YOLOv5 接到视频帧上逐帧做目标检测再用检测框中心点做简单的 IOU 跟踪在画面里画一条虚拟检测线车过线就计数同时把每辆车的坐标和视频时间戳打印出来。项目里还带了一个 myhaar.xml这是早期用 Haar 级联做车辆检测留下的模型文件正好可以拿来做对比实验。如果你需要做毕设里的车辆检测模块、交通流量统计原型或者想快速理解 YOLOv5 如何从“能检测”走到“能计数”这篇文章可以给你一条可复现的路径。2. YOLOv5 网络结构与逐帧检测实现2.1 为什么弃用 Haar 级联而选 YOLOv5Haar 级联在 OpenCV 里一直被视为轻量目标检测的入门方案。这个项目压缩包里的 myhaar.xml就是早期用opencv_traincascade训练的车辆检测器。它采用滑窗扫描图像计算 Haar-like 特征后送入 AdaBoost 分类器判断是否包含车辆。它的数学形式简单模型文件只有几百 KB但实际跑路口监控视频时问题很明显车辆在画面中的尺度变化大远处一辆车可能占 40 像素近处一辆公交占 300 像素滑窗必须用多尺度金字塔反复检测漏检和误检一样多。YOLOv5 则是一条完全不同的技术路线。它把整张图划分成 S×S 的网格每个网格负责预测中心点落在该网格内的目标。主干网络采用 CSPDarknet53将特征图通道分成两条支路再融合减少重复梯度计算的同时保留了深层语义特征。检测头部分使用了 3 个不同尺度80×80 的特征图负责小目标40×40 负责中目标20×20 负责大目标所有尺度通过 PANet 做自顶向下和自底向上的路径聚合让浅层位置信息和深层类别信息互相补强。下面这张表是我实际对比过的差异对比维度Haar 级联YOLOv5输入要求固定尺度灰度图640×640 RGB 即可特征提取手工设计像素差分卷积神经网络自动学习遮挡鲁棒性差局部被挡即失效较好仍有框但置信度下降多类别扩展基本不可能只需改标签和类别数推理速度CPU10-20 FPS5-15 FPS推理速度GPU不支持60-100 FPS从工程角度Haar 级联一旦换一个摄像头阈值几乎都要重新调YOLOv5 如果只用预训练权重COCO 类别中的 car、bus、truck 基本覆盖了城市道路常见车辆直接跑也有不错的效果。因此这个项目把 Haar 文件保留但不再作为主检测器而是作为对照组用于验证深度学习方法在遮挡和尺度变化上的优势。2.2 用 torch.hub 加载模型并逐帧推理逐帧检测的主循环最需要注意的不只是模型调用还有输入分辨率。YOLOv5 默认会用 letterbox 把视频帧缩放成 640×640保持宽高比不变同时用灰色条填充剩余区域。letterbox 的填充逻辑会让输出框坐标偏移到原图坐标系results.xyxy实际上已经做了坐标还原。这一点很容易被忽略如果你自己写前处理必须记录 letterbox 的 scale 和 pad否则画框位置会整体偏右偏下。import cv2 import torch import time # 加载本地训练好的 YOLOv5 模型 model torch.hub.load(ultralytics/yolov5, custom, pathruns/train/exp/weights/best.pt, force_reloadFalse) model.conf 0.35 # 置信度阈值 model.iou 0.5 # NMS 的 IoU 阈值 model.classes [2] # COCO 类别中 car 的索引自定义模型须删除这行 cap cv2.VideoCapture(MOV/intersection.mp4) prev_time time.time() while cap.isOpened(): ret, frame cap.read() if not ret: break # YOLOv5 内部做 letterbox 和归一化xyxy 坐标已经还原到原图 results model(frame) dets results.xyxy[0].cpu().numpy() # [x1, y1, x2, y2, conf, cls] for x1, y1, x2, y2, conf, cls in dets: cv2.rectangle(frame, (int(x1), int(y1)), (int(x2), int(y2)), (0, 255, 0), 2) label f{results.names[int(cls)]} {conf:.2f} cv2.putText(frame, label, (int(x1), int(y1) - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) curr_time time.time() fps 1.0 / (curr_time - prev_time) prev_time curr_time cv2.putText(frame, fFPS: {fps:.1f}, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 255), 2) cv2.imshow(YOLOv5 Vehicle Detection, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()逻辑说明torch.hub.load在第一次运行时会自动下载模型结构代码之后从本地缓存加载path指向你训练好的权重。model.conf控制置信度阈值调低会召回更多目标但误报也随之增加model.iou控制 NMS 时两个同类框的重叠程度值越大越容易合并相邻框。results.xyxy返回的是[x1, y1, x2, y2]形式的像素坐标可以直接在 OpenCV 里画矩形。代码里的fps是实际处理帧率包含了检测和绘制的时间如果它低于视频原生帧率说明处理速度跟不上视频速度。参数说明model.conf 0.35是经验值。白天顺光场景下 0.3 就够用夜间或者雨天建议提高到 0.45压低把路灯、反光牌检测成车的误报。model.iou 0.5控制 NMS 的去重力度车辆密集且互相遮挡时可以降到 0.4让两个靠近的实例保留下来如果同一辆车出现两个重叠框适当提高可减少重复框。如果你用自己的自定义数据集model.classes这一行必须删掉否则会把所有检测结果过滤掉。2.3 基于 IOU 的轻量车辆跟踪跟踪在这里不追求跨摄像头身份保持只需要在视频时间线上维持每辆车短暂的 ID。因为监控摄像头固定相邻两帧间隔 40ms车辆位移在画面中通常不会超过自身框的一半所以用最简单的 IOU 匹配就足够了。IOU 匹配的复杂度是 O(M×N)M 是上一帧追踪目标数N 是当前帧检测框数在 M 和 N 都小于 30 时延迟可以忽略。def iou(bbox1, bbox2): x1 max(bbox1[0], bbox2[0]) y1 max(bbox1[1], bbox2[1]) x2 min(bbox1[2], bbox2[2]) y2 min(bbox1[3], bbox2[3]) inter max(0, x2 - x1) * max(0, y2 - y1) area1 (bbox1[2] - bbox1[0]) * (bbox1[3] - bbox1[1]) area2 (bbox2[2] - bbox2[0]) * (bbox2[3] - bbox2[1]) union area1 area2 - inter return inter / union if union 0 else 0 trackers [] # 每个元素为 [bbox, id, lost_count, prev_bbox] next_id 0 for det in current_dets: best_iou 0 best_idx -1 for i, tracker in enumerate(trackers): iou_val iou(tracker[0], det[:4]) if iou_val best_iou: best_iou iou_val best_idx i if best_iou 0.45: tracker trackers[best_idx] tracker[3] tracker[0][:] # 保存上一帧框 tracker[0] det[:4] tracker[2] 0 else: trackers.append([det[:4], next_id, 0, det[:4]]) next_id 1逻辑说明trackers保存当前活跃目标的框、ID 和连续丢失帧数。每一帧先让上一帧的框和当前帧所有检测框做 IOU 匹配匹配上就更新框位置没有匹配上对目标做“年代偿”处理不立即删除而是把lost_count加一超过 5 帧再移除。这样可以避免车辆被短暂遮挡时 ID 跳变。代码里tracker[3]存放上一帧的 bbox用于下一步的速度外推。参数说明iou_threshold0.45适合摄像头固定、车辆行驶方向接近垂直的场景如果车辆横向移动速度快帧间隔里框重叠少需要降到 0.3 左右。如果车辆在堵车时原地等待IOU 匹配仍然有效因为前后帧的框几乎完全重叠。两辆并排的车换道相撞时容易出现 ID swap这是 IOU 跟踪的边界所在更稳的方案是引入 Deep SORT把 ReID 特征和卡尔曼滤波加进来但算力开销会明显上升。为了弥补检测器偶尔漏检我一般会在连续 3 帧未匹配时把该 tracker 的框按上一帧速度外推一次if tracker[2] 3: vx tracker[0][0] - tracker[3][0] vy tracker[0][1] - tracker[3][1] tracker[0] (tracker[0][0] vx, tracker[0][1] vy, tracker[0][2] vx, tracker[0][3] vy)逻辑说明用最近两帧的坐标差作为速度对当前框做线性外推。外推次数不要超过 5 次否则车辆减速或转向时框会漂移。lost_count达到阈值后直接删除 tracker避免占用 ID 总数。参数说明移动补偿在车辆匀速直线行驶的路段效果好用在堵车场景反而会导致 ID 漂移。你可以根据平均车速决定是否启用如果视频里车辆经常刹车排队把外推次数设成 0丢失后直接新建目标。3. 检测线计数、图形界面与位置输出3.1 虚拟检测线计数逻辑计数不是数画面里有多少辆车而是统计“穿过某条虚拟线”的车辆。常见做法是在画面上定义一条水平线然后检测每一帧的车辆框下边沿中心点当这个点从线的一侧跨到另一侧就对相应方向计数。由于逐帧检测存在抖动直接用中心点容易在边缘反复横跳一般会保存上一帧的位置判断方向。count_line_y 420 # 检测线在画面中的 y 坐标 prev_positions {} # 车辆 ID 对应的上一帧位置 (cx, cy) up_count 0 down_count 0 for track in trackers: bbox, tid, _, _ track cx (bbox[0] bbox[2]) / 2 bottom_y bbox[3] if tid in prev_positions: prev_y prev_positions[tid][1] if prev_y count_line_y and bottom_y count_line_y: up_count 1 elif prev_y count_line_y and bottom_y count_line_y: down_count 1 prev_positions[tid] (cx, bottom_y)逻辑说明这里取的是车辆的底部中心点因为车辆在画面中底部更贴近地面跨线瞬间的抖动比整体中心点小。prev_y count_line_y表示上一帧在检测线下方当前帧检测框底部到达线上方判断为一次向上通过。方向定义要和你的检测线位置配合。当车辆在检测线上停留时由于下一帧的prev_y等于当前bottom_y严格不等式不满足不会重复计数。参数说明检测线并不是一条固定的直线坐标实际项目中要根据视频分辨率调整。如果视频是 1280×720检测线放在 y420 表示路面中段如果摄像头角度倾斜线改成斜线更准确此时判断条件需要换成点到直线距离的符号。计数逻辑和检测频率耦合帧率越高跨线瞬间的判定越精准但算力消耗也越大。3.2 OpenCV 实时显示面板与帧率统计图形界面不需要 QtOpenCV 自带的高层 GUI 足够显示视频、检测框和计数。为了让结果直观通常会在画面顶部画一个半透明黑条把总计数、帧率和当前位置写上去。overlay frame.copy() cv2.rectangle(overlay, (0, 0), (360, 60), (0, 0, 0), -1) frame cv2.addWeighted(overlay, 0.4, frame, 0.6, 0) text_up fUp Count: {up_count} text_down fDown Count: {down_count} fps_text fFPS: {fps:.1f} cv2.putText(frame, text_up, (10, 25), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2) cv2.putText(frame, text_down, (10, 50), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2) cv2.putText(frame, fps_text, (180, 25), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 255), 2) cv2.line(frame, (0, count_line_y), (frame.shape[1], count_line_y), (0, 0, 255), 2)逻辑说明先用cv2.rectangle画一个半透明黑底再用addWeighted和原图混合这样文字不会跟着路面纹理看不清。检测线用红色画和绿色检测框区分。FPS 不是由 YOLOv5 输出的需要自己在主循环里统计。如果你在waitKey后不调用imshowOpenCV 的 GUI 事件循环不会刷新在高分辨率窗口下 FPS 反而会被绘制拖累。参数说明半透明区域的宽度360是估算的如果你显示更多信息要调大混合系数0.4越低越透明但也越容易受视频内容干扰。文字位置可以用cv2.getTextSize动态计算避免不同分辨率下手动调坐标。cv2.waitKey(1)在 64 位 Windows 上返回的 q 是 113仍然有效。对于帧率统计我推荐用一个队列平滑而不是直接用单帧耗时因为单帧波动太大会让界面数字跳变fps_queue [] # 每次循环追加当前帧耗时 fps_queue.append(1.0 / (time.time() - prev_time)) if len(fps_queue) 10: fps_queue.pop(0) fps sum(fps_queue) / len(fps_queue)参数说明fps_queue长度 10 表示约 0.5 秒的窗口太长会让 FPS 变化滞后太短则数字跳变明显。这里的 FPS 是包含检测、跟踪、绘制的综合耗时调试时如果发现 FPS 比模型推理慢很多优先检查是不是cv2.imshow在高分辨率窗口中缩放造成的。3.3 车辆位置信息获取与打印摘要里提到的“车辆位置信息获取并进行输出打印”本质是把检测框的坐标换算成实际空间坐标。如果只是项目演示可以把车辆中心点和底部点写入控制台如果要为交通灯控制服务还需要结合视频时间和绿灯时间。import time while cap.isOpened(): frame_time cap.get(cv2.CAP_PROP_POS_MSEC) / 1000.0 if int(frame_time) % 2 0 and int(frame_time) ! last_output_time: for track in trackers: bbox, tid, _, _ track cx (bbox[0] bbox[2]) / 2 cy (bbox[1] bbox[3]) / 2 print(f[FrameTime {frame_time:.2f}s] ID{tid} fcenter({cx:.1f}, {cy:.1f}) classvehicle) last_output_time int(frame_time)逻辑说明cv2.CAP_PROP_POS_MSEC返回当前视频帧在时间轴上的毫秒位置除以 1000 变成秒。打印位置信息时带上这个时间戳后续就可以把车辆到达检测线的时间和信号灯状态对齐。GetVideotime.py和GetGreenTime.py在这个项目里就是承担时间获取与绿灯时段计算的外部脚本。如果你的视频在部分格式下CAP_PROP_POS_MSEC不精确可以用frame_idx / video_fps自己合成时间。参数说明last_output_time用于防止同一秒重复打印。如果你的视频只有 25 FPS印出的时间戳每 2 秒刷一次足够做日志分析。如果车辆位置要用于轨迹预测建议把所有帧的时间戳保存到 CSV而不是控制台因为控制台输出本身也会占用处理时间。3.4 项目脚本与文件结构我把压缩包里的文件拆开看这更像是一个课题研究中的中间产物。Cardector.py是检测计数主程序GetVideotime.py读MOV/目录下的视频文件输出每个视频的总帧数、FPS 和持续时间GetGreenTime.py则从外部数据比如信号机日志中提取红灯、绿灯的起止时刻用于把车辆到达检测线的时间与信号灯相位做对比。myhaar.xml是备用的传统检测器如果你的环境没有 GPU可以用它先跑一个粗糙版本但计数准确率会明显下降。.idea目录里的文件都是 PyCharm 配置不影响运行。如果你把这个项目移植到新机器只需要保留Cardector.py、GetVideotime.py、GetGreenTime.py、myhaar.xml和MOV/目录其它文件可以删掉。提示MOV/目录名来自 Apple QuickTime 的常见后缀但 OpenCV 读取的是内部编码不依赖文件后缀。如果视频打不开先ffmpeg -i input.mp4查看编码信息H.265 的视频需要构建带 FFMPEG 的 OpenCV。4. 训练自己的车辆数据集与超参数调优4.1 标注与 YOLO 格式转换如果你的视频场景是校园、园区或者自定义车型COCO 预训练权重效果不一定好需要用自己的数据微调。标注工具用 LabelImg 或者任何能输出 YOLO 格式 txt 的软件。每张图片对应一个同名的 txt每行是class x_center y_center width height坐标都是归一化到 0-1 的小数。标注车辆数据集时一个常见误区是把整个车身框得严丝合缝。YOLO 格式要求标注框四边与车辆实际边缘贴合太紧会丢失车轮底部信息太松会让检测框包含大量背景。我一般会在车身边缘外留 2-4 像素的余量这样模型更容易学习到底边位置而计数用到的bottom_y也更稳定。如果拿到的是 Pascal VOC 的 XML 格式需要转换成 YOLO txt核心逻辑如下import glob for xml_file in glob.glob(labels_xml/*.xml): # 解析 XML 并获取图片宽高 w int(root.find(size/width).text) h int(root.find(size/height).text) obj root.find(object) xmin int(obj.find(bndbox/xmin).text) ymin int(obj.find(bndbox/ymin).text) xmax int(obj.find(bndbox/xmax).text) ymax int(obj.find(bndbox/ymax).text) x_center ((xmin xmax) / 2) / w y_center ((ymin ymax) / 2) / h box_w (xmax - xmin) / w box_h (ymax - ymin) / h print(f{0} {x_center:.6f} {y_center:.6f} {box_w:.6f} {box_h:.6f})逻辑说明这个片段只是将单个对象转成一行实际转换时要遍历所有 object并且注意坐标不能超出图像边界。Pascal VOC 的坐标是绝对值除以宽高后变成 0-1 的小数。YOLOv5 读取标签时要求每个 txt 与对应图片放在同一路径的 images 和 labels 下且文件名完全一致。参数说明归一化精度保留 6 位小数足够因为图像宽度不超过 10000 像素时6 位小数对应 0.01 像素的误差。标注完成后按 8:2 比例把图片和标签文件同时移动到 train/val 目录保证标签路径一致。图片不要放在中文路径下否则 OpenCV 和 YOLOv5 容易因为非 ASCII 路径直接报错。4.2 修改配置与训练命令在models/yolov5s.yaml中把nc改成你的类别数在项目根目录创建data.yamltrain: datasets/cars/images/train val: datasets/cars/images/val nc: 1 names: [vehicle]然后执行python train.py --img 640 --batch 16 --epochs 100 \ --data datasets/cars/data.yaml \ --cfg models/yolov5s.yaml \ --weights yolov5s.pt \ --name cars_exp --cache逻辑说明--weights加载 COCO 预训练权重作为初始化加快收敛--cfg是你修改后的模型结构--cache会把图片提前加载到内存省去每轮从磁盘读图的时间。训练过程中权重会保存在runs/train/cars_exp其中best.pt和last.pt分别保存验证集最优和最后一轮的结果。如果只传--weights而不传--cfgYOLOv5 会从权重文件里读结构但你改了nc后需要用--cfg显式指定新结构。超参数对车辆检测的影响很大我整理了常用取值范围表超参数默认值车辆检测场景建议说明lr00.010.01-0.02初始学习率数据量越小越低lrf0.20.1-0.2最终学习率衰减系数mosaic1.00.5-1.0马赛克增强车辆多时降低hsv_h0.0150.0-0.01色调增强避免改变车漆颜色fl_gamma0.00.0-0.5焦点损失小目标占比高时提高box0.050.05-0.1框回归损失权重参数说明车辆检测和普通物体检测的区别在于背景变化相对稳定所以 HSV 增强不要开太大否则车身的红绿色偏移会影响模型对车灯、尾灯等特征的判断。mosaic增强会把 4 张图拼到一起有利于小目标但在车辆这种大目标上容易产生纵横比失真建议不高于 0.5。box权重大会让模型更愿意优化框的坐标在计数任务中更重要。4.3 评价指标与常见问题训练完成后用val.py验证python val.py --data datasets/cars/data.yaml \ --weights runs/train/cars_exp/weights/best.pt \ --img 640 --task val输出的指标里重点看mAP0.5和mAP0.5:0.95。mAP0.5是 IoU 阈值 0.5 下的平均精度反映“框能不能框住车”mAP0.5:0.95则是在多个 IoU 阈值下取平均对框的回归精度更敏感。在车辆计数场景P精确率低会导致把路牌也计数了R召回率低会导致漏检两者需要根据信号灯系统能接受哪类错误来调整。若误报较多提高model.conf若漏检较多降低model.conf或增大--img。车辆检测还经常遇到一个指标陷阱mAP0.5很高但实际画面里小目标车还是漏。原因是小目标在 mAP 计算中的权重被大目标平均掉了。要单看小目标表现可以用--img 1280重新验证或者在 test 时把图片切块只保留小目标子图。另一种做法是在训练数据里把每张图按 2×2 切分增加小目标的数量。常见的训练问题还有类别不平衡城市道路中 car 和 truck 的比例可能达到 20:1训练出的模型对 truck 召回率很低。这时用--cls 0.5提高分类损失权重或者用欠采样把多类样本降到 2:1 左右。我不建议直接改imgsz为 1280 来缓解小目标因为显存需求会膨胀在 Jetson 这样的设备上根本跑不动不如换用更浅层的 P2 输出结构。注意训练时如果出现 lossnan先检查学习率和 batch size 是否过大再把数据里的空标签文件删除。空标签会导致计算 anchor 匹配时除零最早在 loss 前一个 epoch 就爆掉。5. 加速推理与计数准确性验证技巧5.1 用 ONNX 和 OpenCV DNN 替代 PyTorch 推理在 CPU 上跑 YOLOv5 的瓶颈主要在 Python 的逐帧张量拷贝和 PyTorch 的指令调度。导出 ONNX 后用 OpenCV DNN 或 ONNX Runtime能在不换硬件的情况下把 FPS 提高一倍左右。导出命令python export.py --weights runs/train/cars_exp/weights/best.pt \ --img 640 --batch 1 --include onnx --simplify后处理部分需要手动解码输出张量。YOLOv5 的输出格式是[1, 25200, 85]前 4 列为框中心坐标和宽高第 5 列为目标置信度后面的为类别概率。用 OpenCV DNN 加载后需要自己把中心坐标换成左上角坐标再做 NMS。这里容易踩的坑是坐标中心点加宽高还原到原图时要使用 letterbox 的缩放比例和填充偏移。你可以在导出前固定输入尺寸并自己实现一个和 YOLOv5 一样的 letterbox 函数保证训练和部署的一致性。5.2 两段式计数状态机与自检方法计数不准的另一个常见来源是车辆在检测线上刹车。如果只用单线跨线判断车辆在线上来回挪动会被计多次。我用的自检方法是把计数拆成进入、离开两段状态只有完成完整路径才计 1 次。# 每条 track 增加 status 字段init - inside - counted if prev_y enter_line_y and bottom_y enter_line_y: tracker[4] inside if tracker[4] inside and bottom_y exit_line_y: tracker[4] counted total_count 1逻辑说明enter_line_y在下方exit_line_y在上方车辆从下方驶入时先触发 inside穿过出口线才计数即使它在中间倒车回到入口线状态机仍保持 inside但不会触发第二次。这个设计比简单的跨线判断更适合视频中有车辆加塞和让行的情况。两条辅助线之间的距离取决于车辆长度和帧率典型值 20-40 像素。验证计数准确性时我通常会单独跑一段 500 帧的测试视频保存每帧的 tracker 状态和控制台输出再人工按播放画面记录车辆通过数。如果计数结果比人工少 5%优先检查检测线是否离画面边缘太近车辆在进入画面后尚未检测到就被计数如果多 5%优先检查出口线和入口线之间的距离是否太短导致一辆长车被拆成两辆。最后一招是在画面上叠加轨迹点把每辆车的中心点运动轨迹画出来轨迹断裂的位置就是跟踪断掉的位置。本文还有配套的精品资源点击获取
分享:

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

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