1. 为什么海上船舶检测还在用YOLOv7——选型逻辑与性能基线1.1 海上场景对检测模型提出了哪些苛刻要求先说我接手这个项目的背景。某单位需要一套基于视频流的海上船舶检测系统最终输出检测框、类别和置信度叠加在实时画面上同时把结构化数据写进日志文件。白天、夜晚、雾天、浪花反光、远处的小目标各种情况都要尽量扛住。硬件不是顶级服务器只是一块消费级显卡要求单路1080p视频跑到实时以上。这种任务摆在面前模型选型其实没有太多悬念。当时我把YOLOv5、YOLOv7、YOLOv8都跑了一遍对比最后留在项目里的还是YOLOv7。原因很直接YOLOv7在同等精度下的推理速度和显存占用比v5更好而v8虽然方便但某些小目标召回率在海面背景下反而没有v7调参后稳定。网上有人争论v7是不是“过时”但在工程落地这件事上“合适”比“新”重要得多。这套系统的核心链路可以拆成这样视频流读取 → 逐帧预处理 → YOLOv7推理 → 后处理NMS → 坐标映射 → 结果叠加与输出。听起来简单真正把每一环都写稳、写快、写健壮代码量和工作量远超预期。这篇文章就是把这套系统的代码功能逐层拆开讲清楚适合正在做目标检测落地、尤其是海面或者开阔场景的读者参考。1.2 YOLOv7在代码层面真正强在哪从代码组织角度看YOLOv7给了开发者几个很实在的东西。第一个是可复用的模型定义模块。它的models目录里把backbone和head拆得干净改检测头、改anchor、接自定义neck都很方便不需要把整个网络推倒重来。第二个是训练与推理解耦。train.py和detect.py各管各的事导出ONNX、TorchScript、TensorRT都有对应脚本这对部署来说极其重要。很多项目死在“训练好了但导不出来”YOLOv7在这块坑少很多。第三个是辅助训练头机制。这个在代码上体现出一种很典型的工程思路训练时用辅助loss帮助收敛推理时把辅助头丢掉只保留主头。代码里的forward函数会有分支判断训练走一条路推理走另一条路这种设计让模型精度和推理速度同时拿到。后面细看代码时我会指出具体位置。1.3 系统整体功能模块概览这里先把系统功能模块画个轮廓后续章节展开细节模块职责对应代码文件示例输入模块读取本地视频/RTSP流/USB摄像头input_reader.py推理模块模型加载、预处理、forward、后处理detector.py业务模块区域闯入判断、目标计数、单帧上报tracker.py展示模块OpenCV画框、FPS显示、OSD信息display.py存储模块结构化日志、抓拍图片、告警记录logger.py / saver.py这套分层思路是项目一开始就定下的推理逻辑和业务逻辑严格分开。因为检测模型会换、业务需求会变如果两坨代码缠在一起后续改一个地方就要动另一个地方维护成本会迅速失控。2. 代码目录与入口一个完整检测系统是怎么组织起来的2.1 目录结构设计——让新接手的人不用问就能上手我当时拿到这个项目的需求时先没急着写代码而是把目录结构定下来。这个项目最终采用的目录组织方式是这样的marine_vessel_det/ ├── weights/ │ ├── best.pt │ └── vessel_classes.txt ├── configs/ │ └── inference.yaml ├── core/ │ ├── input_reader.py │ ├── detector.py │ ├── postprocess.py │ ├── tracker.py │ └── display.py ├── utils/ │ ├── logger.py │ ├── saver.py │ └── visualization.py ├── main.py ├── export_onnx.py └── requirements.txt简单说明一下设计逻辑。weights目录放权重和类别文件不跟代码混在一起避免模型升级时改动代码。configs目录放yaml配置所有阈值、路径、视频源都在这里改不用翻代码找参数。core目录是核心逻辑utils是辅助功能。main.py是唯一入口export_onnx.py负责模型导出。这个结构对“代码功能说明”来说非常重要你拿到任何一个人的项目第一步永远是看目录目录清楚了代码就懂了一半。2.2 主入口main.py的运行骨架main.py承担的是“启动这一切”的职责但它的代码量并不多。核心流程大致是这样的def run(): cfg load_config(configs/inference.yaml) reader create_reader(cfg[input]) detector VesselDetector(cfg[model]) logger DetectionLogger(cfg[log]) for frame in reader: if frame is None: break dets detector.infer(frame) filtered apply_business_rules(dets, cfg[rules]) logger.write(filtered) if cfg[display][show]: annotated draw_detections(frame, filtered) cv2.imshow(Marine Vessel Detection, annotated)这段代码看起来很短但体现了几个重要设计决策reader是生成器每个yield拿一帧这样视频流和图片文件夹可以共用同一套循环。detector只做“检测”不关心业务规则输入帧输出原始检测结果。业务规则独立成函数区域过滤、置信度二次过滤都放在这里方便按项目定制。显示与存储分离显示不影响日志写入即使关掉画面系统照样记录。我见过很多人的main.py写了上千行什么都往里塞最后自己都改不动。这个项目在代码组织上刻意做了减法效果很好。2.3 参数解析与全局配置别小看这几行argparse再说一个容易被忽略但很重要的模块——参数解析。项目启动时支持这样几种方式python main.py --source configs/inference.yaml python main.py --source rtsp://192.168.1.100:554/stream python main.py --source ./test_videos/sea.mp4 --conf 0.35 --iou 0.5实现上用了argparse但有几个细节值得注意。第一source参数既可以是配置文件路径也可以是视频路径还可以是RTSP流地址运行时根据来源字符串自动判断。统一接口的代价是少了类型检查的便利但换来的是使用上的极大自由。第二命令行参数可以覆盖配置文件里的值。这部分逻辑写在load_config函数里先把yaml读进来转为字典再用命令行parse_args得到的值覆盖对应键。这样既保留了配置文件的批量管理能力又允许临时调参数。第三所有路径都做了解析和校验。权重文件不存在、视频文件打不开、配置文件格式错误程序都会在启动阶段给出明确报错而不是跑到中间才崩。这个习惯帮我排掉了很多低级错误。2.4 系统初始化流程加载模型、读取类别、预热detector.py初始化时会做这样几件事class VesselDetector: def __init__(self, model_cfg): self.device select_device(model_cfg[device]) self.model load_model(model_cfg[weights], self.device) self.class_names load_classes(model_cfg[class_file]) self.stride get_model_stride(self.model) self.warmup()这个warmup是真的有用不是走过场。第一次推理时PyTorch会做CUDA kernel初始化、TensorRT的engine加载速度会明显慢几秒甚至几十秒。项目启动后先拿一帧黑色图像跑一次完整推理把该加载的库、该分配的显存全部准备好业务启动后第一帧就不会卡住。读取类别文件也值得说一下。类别文件每行一个类别名系统启动时读进列表后面画框、写日志都要用。如果训练时类别顺序和推理时不一致所有框都会张冠李戴所以我在初始化时直接把类别文件内容打印到控制台部署时一眼就能核对。3. 核心推理模块拆解从图像输入到检测框输出3.1 数据预处理流程为什么必须做letterboxYOLOv7输入分辨率是640x640但现实中的视频帧几乎不可能是正方形。直接把原图resize成640x640会造成物体严重形变——船在画面里本来就是狭长的再一压缩小船的形态特征就丢了。所以代码里用了letterbox方式等比缩放加灰边填充。def letterbox(img, new_shape640, color(114, 114, 114)): shape img.shape[:2] ratio min(new_shape[0] / shape[0], new_shape[1] / shape[1]) new_unpad (round(shape[1] * ratio), round(shape[0] * ratio)) dw (new_shape[1] - new_unpad[0]) / 2 dh (new_shape[0] - new_unpad[1]) / 2 img cv2.resize(img, new_unpad, interpolationcv2.INTER_LINEAR) top, bottom round(dh - 0.1), round(dh 0.1) left, right round(dw - 0.1), round(dw 0.1) img cv2.copyMakeBorder(img, top, bottom, left, right, cv2.BORDER_CONSTANT, valuecolor) return img, ratio, (dw, dh)这个函数返回三个值其中ratio和pad信息是后面把检测框还原到原图坐标的关键很多人在这一步偷懒丢掉这些信息结果画框位置偏得离谱。别省这个变量。还有一点图像通道顺序要从BGR转RGB因为OpenCV默认读出来是BGR而模型训练时用的是RGB。这一步漏了出来的结果看起来“好像也能检测”但精度会异常下降尤其是带色彩的类别比如船上的红色甲板、白色船体会混淆得很厉害。像素归一化上YOLOv7用的是0到1之间的浮点归一化代码里是img img[:, :, ::-1].transpose(2, 0, 1)然后除以255最后转成Tensor放到对应device。这里有个小技巧transpose(2,0,1)把HWC变成CHW这一步和RGB转换写在一起能少一次内存拷贝代码看着也紧凑。3.2 forward推理与特征输出形状模型推理这行代码是最核心的但也是最短的with torch.no_grad(): pred self.model(img)[0]torch.no_grad()必须写推理阶段不需要梯度计算能省下不少显存和计算时间。self.model(img)得到的是一个预测结果元组[0]取第一个元素这就是主干网络的输出头结果。输出形状是[1, num_anchors, 85]85的含义是4个坐标信息x,y,w,h 1个目标置信度 80个类别得分。但项目里我们只检测船舶类别通常用的模型是单独训练的类别数可能是1类或者几类这时85就变成了5加类别数。这个输出还不能直接用因为YOLOv7的三个检测头对应三种不同的下采样倍数分别是8倍、16倍、32倍。也就是说小目标在大特征图上检测大目标在小特征图上检测三个尺度拼在一起所以num_anchors是一个很大的数。接下来要做的就是把这份原始输出变成人能读懂的检测框列表。3.3 NMS与置信度过滤是怎么衔接的原始预测里包含大量的低置信度框直接画出来画面会糊成一片。代码处理顺序是先做一次置信度过滤再做NMS去重def filter_and_nms(pred, conf_thres0.25, iou_thres0.45, max_det300): output [torch.zeros((0, 6), devicepred.device)] for i, det in enumerate(pred): xc det[..., 4] conf_thres det det[xc] if det.shape[0] 0: continue boxes xywh2xyxy(det[:, :4]) scores det[:, 4] * det[:, 5:].max(1).values keep nms(boxes, scores, iou_thres) det torch.cat((boxes, scores[:, None]), dim1)[keep[:max_det]] output[0] det return output这里有几个细节值得展开说。置信度过滤用的是det[..., 4] conf_thres其中4这个索引对应的是“这个框里有没有物体的置信度”。注意这里还没有乘类别得分是两个独立的东西。后面计算最终得分时用det[:, 4] * det[:, 5:].max(1).values把“有物体的概率”和“最可能的类别概率”乘起来这才是最终score。这个乘法是YOLO系列的标准做法很多新手只取其中一个会导致检测结果偏激进或者偏保守。NMS算法的本质是在重叠度高的框里保留得分最高的那个抑制掉其他得分低的。这里的iou_thres我觉得放在0.45到0.5之间比较合适。太低会压掉密集排列的小船太高会留下大量重复框画面看着乱。max_det300是限制最大检测数量这是工程上很有用的一个参数。海上场景偶尔会出现极端情况比如海面反光被误检成大片目标如果没有这个上限后处理列表无限膨胀帧率会瞬间掉下去。设置一个上限至少能保证系统在异常情况下还能流畅运行。3.4 检测框坐标的映射还原从640x640回到原图到这里得到的所有框坐标都是相对于640x640输入图的而用户要看的是原始分辨率画面上的框所以必须做坐标还原。代码是def scale_coords(img1_shape, coords, img0_shape, ratio_pad): gain ratio_pad[0] pad ratio_pad[1] coords[..., [0, 2]] - pad[0] coords[..., [1, 3]] - pad[1] coords[..., [0, 2]] / gain coords[..., [1, 3]] / gain clip_coords(coords, img0_shape) return coords还原逻辑就三步减掉padding带来的偏移除以缩放比例最后裁剪到原图范围内。clip_coords这一步很容易被忽略但它很有必要。因为坐标还原后可能出现负数或超出画面边缘的值比如一个框的中心在padding区域、物体部分落在画面外。如果不裁剪后面画框时会抛出奇怪的边界问题写日志时数据也很难看。我建议在代码里把scale_coords的输入输出加一行注释把“这里的坐标是从640x640坐标系映射回原图坐标系”这句话写上因为过两个月你自己回来看这段代码也得想半天。3.5 目标跟踪与多帧平滑的自定义扩展基础检测做完后业务上紧接着的问题是同一艘船在连续帧里会被识别成同一艘船还是反复变成新目标答案取决于你用的是frame-level的纯检测还是带跟踪的检测。这个项目的代码里加入了轻量级跟踪。方案不是用DeepSORT那种重模型而是在检测框上做IoU匹配。每一帧的检测框和上一帧的跟踪框算重叠度重叠度大于阈值就认为是同一个目标继承上一帧分配的ID。目标丢失超过一定帧数就销毁ID新目标则分配新ID。def update_tracks(self, dets): if not self.tracks: self.tracks [Track(det, self.next_id()) for det in dets] return self.tracks matched, unmatched_dets, unmatched_tracks associate( self.tracks, dets, iou_threshold0.3) # 更新matched替换unmatched删除超时track这个轻量跟踪模块让我吃到了很大的甜头。计数时不至于一帧一个数因为同一个目标可能连续几帧被检测到无跟踪时计数完全失控。加了跟踪后每个ID只计一次误差小了一个量级。另一个好处是即使某一帧检测漏了只要上一帧有轨迹这一帧可以保持框的位置视觉上更连贯。4. 数据集训练与模型转换代码怎么衔接训练与部署4.1 海上船舶数据集的标注与增强策略这套系统的模型最初用的是公开船舶数据集做预训练但公开数据集里大多是港口场景和船舶特写海上场景的小目标、雾天目标、远距离目标占比很低。直接用这套权重部署上去漏检率会让人崩溃。后来的做法是自建了一套海上场景数据用标注工具给船画框类别分为货船、渔船、客船、其他船四类。代码层面主要做两个操作一是把标注结果转成YOLO格式的txt二是做离线数据增强。离线增强我用得比较多的是旋转±30度、水平翻转、亮度抖动、海雾模拟加一层半透明白色遮罩。海雾模拟这个增强非常有效模型对雾天场景的鲁棒性提升明显比单纯调曝光有用得多。注意旋转增强对船舶检测来说是双刃剑。船是有明确上下方向的旋转180度会让模型学到“倒悬的船”实际部署时反而可能降低精度。我最后只保留±30度范围内的旋转。4.2 训练配置要点anchor怎么设置、loss怎么选训练阶段用到的是YOLOv7仓库自带的train.py但有几个配置参数是反复试过才定下来的。anchor size这事值得多讲几句。YOLOv7会自动从数据里重新聚类anchor但如果你拿默认anchor强行训练海上数据会出现小目标召回率偏低的问题。因为海上目标的像素尺寸普遍偏小默认anchor尺寸可能偏大。解决办法是在训练前跑一次utils.autoanchor.py让程序根据你的数据集重新计算anchor代码会打印出适合当前数据集的anchor尺寸然后存到模型配置里。这一步不是可选的是必须的。loss方面glou_loss和cls_loss的比例权重用的是默认值但我在实际调参中发现海上小目标场景适当提高cls_loss权重比如从默认0.5调到0.7能略微提高小船的分类准度代价是训练时间变长。这个只是经验值不同数据集表现可能不一样建议还是做AB对比。4.3 从PyTorch权重到ONNX再到TensorRT的导出环节训练完后模型文件是best.pt接下来要导出成推理效率更高的格式。项目里写了一个export_onnx.py脚本核心逻辑参考了YOLOv7仓库的export.py但做了两点定制。第一点是去掉训练相关分支。export时设置tracedTrue走推理分支这样ONNX图里不会包含辅助头相关节点导出的模型体积更小、推理更快。第二点是固定输入尺寸。虽然YOLOv7理论上支持动态输入但ONNX转TensorRT时动态尺寸会显著增加engine构建时间和显存占用。这个项目固定为640x640输入换来更快的转换和更稳的帧率。代价是输入比例极端的长条形画面会被letterbox掉大量边但实际海上监控画面的宽高比都还算正常这个取舍是划算的。TensorRT的engine文件生成之后推理代码从加载PyTorch模型改成加载engine。代码里做了抽象两种加载方式走同一个load_model接口根据文件后缀自动判断if weights.endswith(.pt): model load_pytorch_model(weights) elif weights.endswith(.engine): model load_tensorrt_engine(weights)这个设计让模型切换只改配置文件里的路径代码一行不用动。系统里同时保留PyTorch和TensorRT两套后端调试时用PyTorch正式跑用TensorRT两种模式可以在线切换非常方便。5. 部署过程中的坑我实际遇到的几个问题5.1 海上反光导致漏检预处理阶段的调整第一个坑是海面反光。太阳低角度时海面会形成大片亮斑这些亮斑有时被当作船体有时又会把真正的小船“吞”进去。模型的置信度在这种场景下剧烈波动同一个目标前一秒0.8后一秒0.15。排查后发现问题一半出在预处理。YOLOv7训练时用的图像亮度分布和现场摄像头差异太大尤其是海面高光区域。我当时的调整办法是在预处理链路里加一个**自适应直方图均衡化CLAHE**步骤处理后再进网络高光区域的细节被局部增强小船轮廓能保留下来。clahe cv2.createCLAHE(clipLimit2.0, tileGridSize(8, 8)) frame[:, :, 0] clahe.apply(frame[:, :, 0]) frame[:, :, 2] clahe.apply(frame[:, :, 2])注意这里只对B和R通道做CLAHEG通道不动不然整体色调会偏得离谱。实测这个改动把中午强光时段的漏检率降了约三成代价是每帧多花几毫秒预处理时间可以接受。5.2 小目标船舶检测tile分块推理的取舍第二个坑是远处的小目标。两公里外的一艘渔船在1080p画面里可能只有30x40像素640x640缩放后变成大约17x23像素这对任何检测器都是极限。直接提高输入分辨率到1280能改善但推理时间按平方上升TensorRT跑起来也只有十几帧。后来项目里加了tile分块推理的选项把原图切成几块有重叠的patch每块独立推理最后把检测框合并回原图坐标。这个思路很直接但坑也明显一个目标如果正好被切在patch边缘可能一半在A块一半在B块检测成功率会下降。解决方案是把patch之间的重叠区设成50像素合并时对重复框按置信度去重。用代码表示就是tiles split_with_overlap(frame, tile_size640, overlap50) for tile, offset in tiles: dets detector.infer(tile) dets shift_coords(dets, offset) # 坐标加回偏移量 all_dets.extend(dets) final_dets nms_on_all(all_dets, iou_thres0.5)分块推理把远距离小目标召回率提高了不少但代价是计算量成倍增加一帧要做多次完整推理。实际部署时这个模式是可选开关默认只在重点监控时段开启。如果你项目里小目标特别多还是要优先考虑直接换更高分辨率输入或者用专门的小目标检测模型。5.3 多线程加载与显存释放长时间运行的稳定性第三个坑是显存泄漏。系统要24小时不断跑视频流从早到晚不停。跑上几个小时之后显存占用缓慢上涨最终CUDA out of memory崩掉。问题根源在于一段时间我图省事在一个循环里创建了太多中间Tensor没有释放。后来做了两件事解决在循环体内尽量复用预处理结果容器避免不断新建Tensor。每隔一段时间主动清理CUDA缓存torch.cuda.empty_cache()尤其如果代码里还有周期性加载模型的操作这个调用必须加上。读取视频流那边也有类似问题。RTSP流偶尔断线重连如果用OpenCV默认的VideoCapture会一直阻塞或者返回空帧。我封装了一个重连逻辑读取超时或连续N帧为空时自动销毁当前capture并重新创建线程sleep 2秒再试这样断线后能自动恢复不用人工干预。5.4 误检虚警的二次过滤连接域与先验规则最后一个坑是虚警。海上场景里浪花、波纹、云影、飞鸟都可能变成误检框。模型训练得再好也不能完全消除虚警所以业务层必须有二次过滤机制。代码里做的是这样几个规则最小面积过滤检测框像素面积小于设定值比如原图800x600时小于150像素直接丢弃这些小框大半是噪点。宽高比过滤船的宽高比一般在一个合理范围内如果检测框极端细长比如宽高比大于5多半是浪花条纹或雷达伪影丢掉。位置先验过滤只保留在指定监控区域内的检测框区域外的对象不属于系统关注范围直接忽略。帧间一致性过滤同一个目标必须在连续K帧内至少被检测到M次才确认为有效目标防止单帧偶发虚警。前面三个规则都在postprocess.py里实现最后一个依赖tracker模块的计数功能。整套二次过滤跑下来虚警率降了大概一半而真正漏掉的目标极少——关键船只在连续帧中通常都能被捕获所以一致性过滤不会误伤。6. 参数调优与后续扩展方向6.1 置信度、NMS阈值、目标跟踪参数怎么搭配系统里最需要调的参数是这几个conf_thres、iou_thres、跟踪匹配阈值、目标丢失帧数。它们不是独立变量之间的相互作用很容易被忽略。我用表格整理一下实测中的搭配倾向参数推荐范围实际场景说明conf_thres0.25 ~ 0.45近岸复杂环境调高到0.4以上开阔远海可放低到0.25iou_thres0.4 ~ 0.5目标密集场景用0.4稀疏场景用0.5更好跟踪匹配IoU0.2 ~ 0.35目标运动快的场景调低到0.2慢速场景可以调高目标丢失帧数15 ~ 60希望稳定计数时设大一点比如30帧最大检测数100 ~ 300画面目标多就调大但别超过300否则影响帧率组合上的个人经验是如果你发现虚警多优先调conf_thres而不是盲目加后面的一堆过滤规则如果你发现同一艘船被反复计数优先调跟踪匹配阈值而不是拉高NMS阈值。很多人在NMS上死磕结果发现真正的问题在tracker白费功夫。6.2 后续可以继续扩展的方向这套系统跑通之后有几个扩展方向我觉得特别顺代码结构已经替这些未来需求留好了位置。第一个是后端升级成最新的检测架构。因为加载模型已经做了抽象类和接口都不变换模型时只需改配置文件和权重路径。代码层面无需任何改动这是当时坚持分层设计的最好回报。第二个是接入海图电子围栏逻辑。目前区域过滤只是简单的多边形框选后续可以在业务规则模块里加更精细的电子围栏判断比如某个航道内船只进入时限速提示、离开航道时告警这些都可以在apply_business_rules里扩展。第三个是航道拥堵分析。现在tracker已经能输出每个目标的坐标、速度、方向如果把速度矢量信息和地图网格结合就能算出各区域的船舶密度和流量。代码上的改动主要在数据处理层核心检测模块完全不动。最后一个方向是夜间红外与可见光融合检测。如果前端升级成双光摄像头红外图可以在预处理链路里和可见光图做像素级融合输入给同一个模型或者单独用红外模型再合并一次检测结果。这个在系统架构上只需要新增一个输入源和融合函数其他模块都能复用。这套系统从代码层面讲最值得骄傲的不是用了多复杂的模型或者多前沿的技巧而是每一层都足够清晰每一环都考虑到了实际运行的稳定性。检测这件事本质上是在算力、精度、稳定性之间做平衡而代码就是那个把平衡落到实处的载体。最后分享一个小经验拿到这类系统代码不要急着跑通全部功能先把推理链路单独抽出来测拿一张图从输入到输出走一遍把中间每一层的形状和数值都打印出来。这一步做扎实了后面无论换模型、调参数还是加功能你心里都会特别有底。