
简介基于YOLOv8的港口船舶吃水线实时监测预警系统面向计算机视觉方向的学生与开发者可直接用于毕业设计、课程设计或目标检测项目立项演示。整套方案涵盖完整源码、处理好的数据集、可视化交互界面与部署说明简单配置即可运行。资源包共97个文件以70个Python脚本为主体包含检测服务、训练模式、可视化页面等模块另附4个预训练模型权重.pt、XML配置、图标及一段mp4演示视频压缩包大小约24.21MB整体结构清晰便于按需调用。目前已吸引59人学习浏览。项目中内置了模型训练与推理脚本可生成混淆矩阵、F1分数曲线、精确率-召回率曲线、验证集预测结果及标签分布图等可视化评价指标方便在答辩或报告中直观展示效果适合需要快速搭建完整可演示系统的用户。1. 港口船舶吃水线监测为什么选中 YOLOv8先看懂这个标题在解决什么问题船舶靠离泊和过闸时吃水深度是判断“能不能安全通过”的关键参数。传统做法是派人到岸边目测船舶水尺刻度夜间、雾天、涌浪大的时候读数误差很大而且人工记录没法实时预警。标题里这套方案把 YOLOv8 作为检测核心用摄像头画面实时框出船舶吃水线位置再结合标定关系换算成吃水数值超限就触发预警。它面向的是毕设、课程设计这类有完整交付物要求的场景源码能跑、数据集齐、带可视化界面、有部署教程属于“简单部署即可运行”的项目类型。对这个标题感兴趣的人通常手里已经有一批港口监控视频或水尺图片但没想清楚“检测吃水线”到底该用目标检测还是图像分割也不确定 YOLOv8 的哪些参数直接影响吃水线判读精度。这篇文章就按我实际做类似项目的路径展开先讲清楚检测目标如何定义再给出一套从数据标注到模型训练、再到界面联调的完整可复现流程最后把最容易让项目翻车的环境、标定和阈值问题单独拿出来说。2. 吃水线检测的技术拆解为什么不能用通用目标检测的思路直接做2.1 检测目标不是“船”而是“水线边缘”和“水尺刻度”两个区域多数人拿到港口船舶图片后第一反应是训练一个 YOLOv8 模型去检测整艘船。这在技术上行得通但对吃水监测没有意义监测系统需要的是船体与水面的交界线位置以及船首或船尾水尺标牌上的刻度读数。这两个目标的尺度差异很大——船体可能占整张图的 60%水尺刻度却只占几个像素宽。我一般会把标注类别拆成两类一类是waterline表示船体侧面与水面的分界区域用水平方向的矩形框去贴合这根线另一类是draft_mark表示水尺刻度牌区域矩形框尽量只框住刻度数字和刻度线不包含周围船体。模型输出这两个类别后系统再用后处理逻辑去“读”吃水值而不是让模型直接回归一个数字。这样的拆分让 YOLOv8 的任务变得简单它只需要回答“这根线在哪”和“刻度牌在哪”不需要理解水深物理意义。换算吃水值的工作交给像素标定去做准确度和可解释性都更好。如果你的数据集里只有整船框的标注我建议重新标注否则训练出来的模型在后处理阶段会非常痛苦。2.2 YOLOv8 在实时性上的选型理由n/s 模型满足港口监控的帧率需求港口监控场景对实时性的要求是“秒级预警”不需要毫秒级响应。一个 200 万像素的摄像头跑 YOLOv8n 在普通 GTX 1660 Ti 上能做到 30ms 左右一帧也就是 30FPS 以上跑 YOLOv8s 大约 45ms 一帧仍然够用。这个项目的模型权重通常不超过 20MB部署到工控机上压力很小。对比来看如果选用 YOLOv5模型更成熟但 anchor-free 机制不如 v8 干净如果选用 RT-DETR 这类 transformer 检测器精度可能更高但部署依赖和推理耗时对课设项目不友好。YOLOv8 的另一个优势是它的训练管线对新手最友好数据格式和 YOLOv5 基本一致ultralytics包把训练、验证、导出、推理全部封装成命令行和 Python API不需要手写 NMS 和后处理。需要提醒的是吃水线检测的难点不在“检测框的类别准确率”而在“框的位置精度”。YOLOv8 的 loss 设计对边框回归本身就比较敏感但如果你用默认参数去训练一个只有几百张图的小数据集边框抖动会很明显。这个后面会展开讲。2.3 可视化界面里到底要展示什么图像帧、检测框、吃水数值、预警状态标题里提到的可视化界面不是把检测结果画个框就算完。一个能交给老师或甲方演示的界面至少要包含四块内容实时视频流显示区、检测结果叠加层、吃水数值显示面板、预警日志列表。我用 PySide6 做过一版界面结构大概是左侧是QLabel或QGraphicsView显示摄像头帧检测框用QPen画在帧上右侧用QTableWidget展示每条船的检测时间、吃水值、预警等级底部是日志区域记录每次预警的截图和参数。界面和检测逻辑之间用QThread分离否则视频流会把 UI 线程卡死。如果你不想从零写界面可以直接用 ultralytics 自带的predict回调把帧交给界面刷新函数。核心代码模式下面会给到。3. 从零跑通 YOLOv8 吃水线检测数据集处理到模型训练的最小闭环3.1 构建数据集把单张图片切出“船舶吃水线”和“水尺刻度”两类目标标题附带的数据集如果是别人标注好的你主要做格式检查和划分如果是自己采集的港口图片我建议遵循以下步骤。第一步是图片筛选剔除夜间完全看不清水线的、船体被遮挡超过三分之一的、以及水面反光让水线无法辨认的图。第二步是统一分辨率训练图片不需要太大1280×720 就够过大反而让模型学到过多无关纹理。标注工具我推荐 LabelMe 或 anylabeling。LabelMe 导出的是 JSON 文件需要用脚本转换成 YOLO 格式的 txtanylabeling 可以直接配置 YOLO 输出格式少一步转换。标注时注意两点waterline框的上下边界尽量贴合水面线附近的船体区域不要包含大面积水面draft_mark框只框刻度牌不要框进船舷编号。下面这段脚本把 LabelMe 的 JSON 转成 YOLO 格式是每个做 YOLO 项目的人都会用到的模板import json import os from pathlib import Path # 类别映射0 表示吃水线1 表示水尺刻度 CLASS_MAP {waterline: 0, draft_mark: 1} def labelme_to_yolo(json_path, output_dir, img_w, img_h): with open(json_path, r, encodingutf-8) as f: data json.load(f) txt_name Path(json_path).stem .txt out_lines [] for shape in data[shapes]: label shape[label] if label not in CLASS_MAP: continue points shape[points] # points 是多边形顶点取外接矩形作为检测框 xs [p[0] for p in points] ys [p[1] for p in points] x_min, x_max min(xs), max(xs) y_min, y_max min(ys), max(ys) # 归一化到 [0,1] cx (x_min x_max) / 2 / img_w cy (y_min y_max) / 2 / img_h bw (x_max - x_min) / img_w bh (y_max - y_min) / img_h # 过滤掉异常小的框可能是误标点 if bw 0.01 or bh 0.01: continue out_lines.append(f{CLASS_MAP[label]} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}) with open(os.path.join(output_dir, txt_name), w) as f: f.write(\n.join(out_lines))逻辑说明LabelMe 的points是任意多边形的顶点列表这里取最小外接矩形近似为检测框。对于waterline这种细长目标外接矩形的宽度远大于高度YOLOv8 对这种极端长宽比目标是支持的但训练时要注意 anchor 匹配——后面会提到调参。参数说明img_w和img_h必须与图片实际分辨率一致否则归一化坐标会错位。如果你的数据集来自视频抽帧注意不能把同一段视频的连续帧都放进训练集否则训练集和验证集会高度重复验证指标虚高。3.2 划分训练集并检查标签分布一张图两种目标的坐标冲突问题数据集划分我习惯用train_val_split.py脚本来做按 8:1:1 分成训练、验证、测试三份。划分的时候按图片名随机分不要按视频片段分保证同一条船的多个视角尽量只出现在一个集合里。import os import random import shutil from pathlib import Path random.seed(42) img_dir Path(images) label_dir Path(labels) train_img Path(train/images) val_img Path(val/images) test_img Path(test/images) for p in [train_img, val_img, test_img]: p.mkdir(parentsTrue, exist_okTrue) imgs list(img_dir.glob(*.jpg)) list(img_dir.glob(*.png)) random.shuffle(imgs) n len(imgs) train_end int(n * 0.8) val_end int(n * 0.9) for idx, img_path in enumerate(imgs): label_path label_dir / (img_path.stem .txt) if not label_path.exists(): continue if idx train_end: dest_img, dest_label train_img, Path(train/labels) elif idx val_end: dest_img, dest_label val_img, Path(val/labels) else: dest_img, dest_label test_img, Path(test/labels) dest_label.mkdir(parentsTrue, exist_okTrue) shutil.copy(img_path, dest_img / img_path.name) shutil.copy(label_path, dest_label / label_path.name) print(ftrain: {train_end}, val: {val_end - train_end}, test: {n - val_end})逻辑说明只有图片没有对应 txt 标注的样本直接跳过避免训练时出现“空标签”导致的 loss 异常。随机种子固定下来方便复现。参数说明8:1:1 是通用比例如果数据集只有 300 张建议改成 8:2:0把测试集并入验证集因为 30 张测试图不足以给出稳定的评估结论。划分完成后强烈建议写一段代码统计每个类别在每个集合中的框数量分布防止某类目标全部集中在训练集而验证集一个都没有。3.3 编写数据配置文件并启动训练YOLOv8 训练参数按这个小规模数据集来调YOLOv8 使用 ultralytics 框架训练前需要创建一个data.yaml文件内容是类别名和路径。注意path字段写的是数据集根目录train和val用相对路径这样换机器部署时不用改配置。# data.yaml path: /home/user/draft_detection_dataset train: train/images val: val/images test: test/images nc: 2 names: 0: waterline 1: draft_mark然后启动训练。对于课设级的数据量我建议用 YOLOv8s 而不是 n因为 n 模型的容量在细长目标上容易欠拟合如果显卡显存小于 4GB再退回 n。yolo detect train \ modelyolov8s.pt \ datadata.yaml \ epochs100 \ imgsz640 \ batch16 \ lr00.01 \ patience20 \ projectdraft_detection \ nameexp1 \ device0逻辑说明modelyolov8s.pt是在 COCO 预训练权重基础上微调吃水线目标虽然和 COCO 里的boat不完全一样但低层特征边缘、纹理是通用的微调收敛速度远快于从零训练。patience20表示验证集 mAP 连续 20 个 epoch 不提升就提前停止防止过拟合。参数说明imgsz640是默认值但如果你标注的waterline框高度普遍小于 20 像素建议把imgsz提高到 960 甚至 1280代价是显存占用增加约 2 倍。batch的取值要参考显存GTX 1660 Ti 6GB 显存跑 640 分辨率时batch16是安全的。lr0我用 0.01 而不是默认的 0.02因为小数据集上大学习率容易震荡。3.4 训练过程中的验证指标怎么看mAP50 高但 mAP50-95 低的含义训练结束后ultralytics 会在draft_detection/exp1/下生成results.csv和weights/best.pt。我判断模型能不能用不会只看最后的 mAP 数字而是看两点一是val/box_loss是否还在下降如果训练结束前 loss 曲线还在走低说明epochs不够二是看mAP50-95和mAP50的差值两者差距超过 0.2 说明检测框定位精度不足。吃水线监测对定位精度要求高框偏 5 个像素换算成吃水值可能就是 0.1 米的误差。因此我一般以mAP50-95为主要参考指标而不是mAP50。如果mAP50-95低于 0.5先不要急着调模型结构优先检查标注框是否贴边、是否存在大量长宽比超过 1:10 的极端框。4. 部署推理与可视化界面把模型接到实时视频流和预警逻辑上4.1 用训练好的权重做单张图片和视频推理先验证效果再谈界面部署的第一步不是写界面而是用命令行验证模型在真实图片上的泛化效果。我建议先跑单张图片推理观察waterline和draft_mark两个类别的置信度分布。yolo detect predict \ modeldraft_detection/exp1/weights/best.pt \ sourcetest_images/port_001.jpg \ conf0.35 \ iou0.45 \ saveTrue \ save_txtTrue \ projectruns/predict \ nametest1逻辑说明save_txtTrue会把每个检测框的类别、归一化坐标和置信度写入 txt 文件这对接下来的吃水值计算非常重要因为后处理逻辑需要读这些坐标而不是重新解析图片。参数说明conf0.35是门限值吃水线检测场景我建议设得低一些因为细长目标的置信度天然低于普通目标如果你发现大量误检再逐步提高到 0.5。iou0.45控制 NMS 的框合并强度两个类别之间不存在竞争关系所以这个值对类间误检影响不大主要影响同一类别的重叠框合并。4.2 吃水值换算从像素坐标到实际吃水深度的标定公式检测框本身不产生吃水值需要做像素到物理尺寸的标定。常见做法是在摄像头画面中找一个已知实际尺寸的参考物比如船体上的标准水尺刻度牌读取刻度牌上相邻两个刻度线的像素距离再根据实际刻度间距通常每格 10cm 或 20cm计算像素比例尺。吃水线的检测结果本质上是“水面线在船体侧面的纵向位置”但摄像头画面中船体是斜的所以不能只取框中心的 y 坐标。我一般先取draft_mark框的 y 方向范围然后计算waterline框中心 y 坐标在draft_mark框高度范围内的比例位置再乘以水尺量程。import cv2 import numpy as np from ultralytics import YOLO model YOLO(draft_detection/exp1/weights/best.pt) def calc_draft(results, scale_cm_per_pixel0.5, draft_mark_bottom12.0): 根据检测框计算吃水值。 scale_cm_per_pixel: 像素到实际尺寸的比例需要现场标定。 draft_mark_bottom: 水尺刻度牌最底部对应的实际吃水值米。 drafts [] for r in results: boxes r.boxes waterline_boxes [] draftmark_boxes [] for box in boxes: cls int(box.cls[0]) x1, y1, x2, y2 box.xyxy[0].cpu().numpy() if cls 0: waterline_boxes.append((x1, y1, x2, y2)) elif cls 1: draftmark_boxes.append((x1, y1, x2, y2)) for wl in waterline_boxes: wl_cy (wl[1] wl[3]) / 2 # 找到与当前水线横向范围重叠的水尺框 best_dm None best_overlap 0 for dm in draftmark_boxes: overlap min(wl[2], dm[2]) - max(wl[0], dm[0]) if overlap best_overlap: best_overlap overlap best_dm dm if best_dm is None: continue dm_top best_dm[1] dm_h best_dm[3] - best_dm[1] if dm_h 1: continue # 水线在刻度牌高度内的归一化位置0 顶部1 底部 ratio (wl_cy - dm_top) / dm_h ratio max(0.0, min(1.0, ratio)) # 吃水值 刻度牌底对应的真实吃水 - 归一化高度 * 量程 draft draft_mark_bottom - ratio * (dm_h * scale_cm_per_pixel / 100.0) drafts.append(round(draft, 2)) return drafts cap cv2.VideoCapture(0) while True: ret, frame cap.read() if not ret: break results model.predict(frame, conf0.35, verboseFalse) drafts calc_draft(results) for d in drafts: print(current draft:, d) # 在 frame 上绘制检测框和吃水值 annotated results[0].plot() cv2.imshow(draft_monitor, annotated) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()逻辑说明calc_draft先按类别拆出两类框然后在每个水线框的横向范围内寻找重叠度最高的水尺框。这里没有直接用waterline框的 y 坐标映射全局像素而是换算到水尺框内部的比例位置这样能抵消摄像头安装角度带来的透视误差。参数说明scale_cm_per_pixel0.5是假设像素比例尺为每像素 0.5cm实际值需要现场用已知刻度间距标定否则吃水值误差会线性放大。draft_mark_bottom12.0是水尺牌底部的零点偏置不同船只需要单独设定我在代码里把它暴露成参数就是为了方便调试。4.3 PySide6 可视化界面的最小实现视频线程、检测线程和预警信号界面部分我用 PySide6 和 QThread 实现。核心思路是视频采集线程不停读帧把帧通过信号发送给主界面主界面在槽函数里调用模型推理然后在 QLabel 上显示结果。模型推理放在主线程会卡界面所以我把推理也放到独立线程里。import sys import cv2 from PySide6.QtWidgets import QApplication, QLabel, QMainWindow, QVBoxLayout, QWidget from PySide6.QtCore import QThread, Signal, Qt from ultralytics import YOLO class VideoThread(QThread): frame_ready Signal(object) def __init__(self, source0): super().__init__() self.source source self.running True def run(self): cap cv2.VideoCapture(self.source) while self.running: ret, frame cap.read() if ret: self.frame_ready.emit(frame) self.msleep(30) cap.release() class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle(港口船舶吃水线实时监测预警) self.label QLabel() layout QVBoxLayout() layout.addWidget(self.label) container QWidget() container.setLayout(layout) self.setCentralWidget(container) self.model YOLO(draft_detection/exp1/weights/best.pt) self.thread VideoThread(source0) self.thread.frame_ready.connect(self.process_frame) self.thread.start() def process_frame(self, frame): results self.model.predict(frame, conf0.35, verboseFalse) annotated results[0].plot() # 将 BGR 转 RGB 并显示到 QLabel rgb cv2.cvtColor(annotated, cv2.COLOR_BGR2RGB) h, w, ch rgb.shape bytes_per_line ch * w from PySide6.QtGui import QImage, QPixmap image QImage(rgb.data, w, h, bytes_per_line, QImage.Format_RGB888) self.label.setPixmap(QPixmap.fromImage(image).scaled( self.label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation)) if __name__ __main__: app QApplication(sys.argv) win MainWindow() win.show() sys.exit(app.exec())逻辑说明frame_ready信号把 OpenCV 的 numpy 帧传到主线程槽函数推理在槽函数里执行。self.msleep(30)让视频线程以约 33FPS 的速度产生帧避免 CPU 空转。参数说明source0表示读取本机摄像头如果你想测试本地视频或 RTSP 流把source改成视频文件路径或rtsp://ip:port/stream即可。Qt 槽函数里的推理是同步的如果模型推理耗时超过 30ms界面帧率会下降这时需要把推理也移到独立线程中我为了展示主流程没有做二次拆分。5. 避坑与常见问题环境、数据、标定三处最容易翻车的地方5.1 现象一ultralytics装好了但yolo命令无法识别原因在 Windows 上使用 Anacondayolo命令安装到了 Python 环境的 Scripts 目录下但该目录没有加入 PATH。还有一种情况是虚拟环境没激活就跑命令系统调用了全局 Python 的 yolo 入口而不是当前环境的。解决先执行pip install ultralytics确认安装路径然后在虚拟环境中用python -m ultralytics代替yolo命令或者在 Anaconda Prompt 中运行conda activate env后再执行。Ubuntu 20.04 下如果遇到相同问题检查~/.local/bin是否在 PATH 中。5.2 现象二训练过程中 loss 正常下降但验证集 mAP 全程为 0原因最常见的是数据集划分后验证集里没有标注文件。例如 val 目录下有图片但没有对应的 labels 目录ultralytics 会认为这些图片没有目标mAP 自然为 0。其次是 data.yaml 中的类别顺序和标注文件不匹配比如代码里CLASS_MAP把waterline定义为 0yaml 里定义的names: {0: draft_mark}模型就会完全学反。解决用如下脚本检查 val 目录标签文件数量是否与图片对应。另一个更隐蔽的原因是 LabelMe 转换时坐标除以了错误的图片宽度导致归一化坐标超出 [0,1] 范围ultralytics 会静默丢弃这些框。python - EOF from pathlib import Path val_img Path(val/images) val_label Path(val/labels) for img in val_img.glob(*.jpg): label val_label / (img.stem .txt) if not label.exists(): print(fmissing label: {img}) EOF5.3 现象三界面显示实时视频但检测框完全不出现原因没有在QThread中启动摄像头时指定正确的解码方式。OpenCV 在 Windows 上读取 RTSP 流时有时会丢帧导致ret一直为 False界面显示的是空画面或者模型推理时抛了异常但异常被 Qt 槽函数吞掉了界面看似正常但不更新。解决先在命令行用cv2.VideoCapture单独测试视频流能否正常读取再排查模型推理是否报错。给槽函数加try-except并把异常打印到控制台是定位这种问题最快的方式。5.4 现象四吃水值计算结果明显偏大或偏小同一画面两次结果不一致原因这不是模型的问题而是标定比例尺不准确。我见过最典型的错误是直接用图片整体高度去换算吃水值但船体侧面在画面中是倾斜的不同吃水深度对应的像素比例尺不一样。另一个常见问题是waterline框抖动导致中心 y 坐标波动ethically吃水值跟着跳。解决固定摄像头安装角度并在软件里限定“只计算 waterline 框高度小于某个阈值”的检测结果过滤掉检测不稳定的细长框。同时在标定阶段用多张不同吃水状态的照片反推比例尺取中位数而不是单张标定值。5.5 现象五YOLOv8 训练时显存溢出batch 降到 4 还是跑不动原因imgsz设置过大或者数据集的图片尺寸不一致导致 ultralytics 在训练时按最大尺寸做 letterbox。另一个原因是 PyTorch 和 CUDA 版本不匹配导致显存管理效率低下。解决把训练参数改成imgsz640并用cacheTrue把图片缓存到内存减少磁盘 I/O。如果你的显卡只有 4GB 显存把batch4、workers2设成固定值并关闭amp以外的所有额外功能。如果是 CPU 环境参考 Ubuntu 20.04 CPU 版的环境搭建流程也是一种可行方案只是训练时间会拉长数倍我建议至少用云 GPU 或 Google Colab 完成训练。6. 进阶把单点检测升级为连续实时预警的可靠系统6.1 加入时序平滑用滑动窗口消除吃水值抖动单帧推理的吃水值即使标定正确也会因为水面波动、船舶轻微摇摆而上下跳动。视觉上看似模型不稳实际上是物理噪声。我用一个长度为 5 的滑动窗口对吃水值做中值滤波窗口内的中位数作为显示值避免单个离群点触发误报。from collections import deque class DraftSmoother: def __init__(self, window_size5): self.buffer deque(maxlenwindow_size) def update(self, draft_value): if draft_value is None: return None self.buffer.append(draft_value) if len(self.buffer) 2: return draft_value sorted_vals sorted(self.buffer) return sorted_vals[len(sorted_vals) // 2]逻辑说明中值滤波对脉冲噪声的抑制效果比均值好水面突然反光导致的一组错误高值不会污染输出。deque(maxlen5)自动丢弃旧值实现成本几乎为零。参数说明窗口大小 5 对应约 1 秒的平滑时长。如果港口涌浪较大可以改成 9但窗口过大时真实吃水变化也会被延迟预警响应时间变长需要权衡。6.2 预警阈值设计一二级预警分别处理“接近限值”和“超过限值”吃水预警不是简单判断“超过最大值就报警”。港口通常对吃水限制分几个等级接近限制值提醒超过限制值告警。我借鉴工业控制里的“双阈值”思想设置warn_threshold和alert_threshold吃水值进入两者之间时触发黄色预警超过后触发红色预警。并且连续 3 帧都超阈值才真正触发防止单帧误检。class DraftAlert: def __init__(self, warn_threshold11.5, alert_threshold12.0, confirm_frames3): self.warn_threshold warn_threshold self.alert_threshold alert_threshold self.confirm_frames confirm_frames self.counter 0 def check(self, draft_value): if draft_value is None: self.counter 0 return None level normal if draft_value self.alert_threshold: level alert elif draft_value self.warn_threshold: level warn if level normal: self.counter 0 else: self.counter 1 if self.counter self.confirm_frames: return level return None逻辑说明confirm_frames3是关键它让系统不会因为一个异常帧就报警也不会因为一次漏检就撤销正在报警的状态。实际部署时报警状态持续期间会截图保存到本地作为事后追溯的证据。参数说明阈值需要根据港口具体水深和船舶吃水限制来设置。如果你是毕设展示可以把阈值做成配置文件或界面输入框演示时动态调整更直观。6.3 模型轻量化导出为 TensorRT 或 ONNX 提高推理速度如果部署环境是 NVIDIA Jetson 或 RK3588 这类边缘设备PyTorch 原模型推理速度不够。ultralytics 提供一行命令导出 ONNXyolo export modeldraft_detection/exp1/weights/best.pt formatonnx dynamicFalse imgsz640导出后用 ONNX Runtime 推理速度比 PyTorch 快 30% 到 50%如果设备支持 TensorRT再执行一步转换。需要提醒的是吃水线目标很细导出时不要为了提速随意降低imgsz否则检测精度会明显下降。ONNX 模型推理时输入的图片要使用相同的前处理逻辑包括 letterbox 和归一化否则结果会和 PyTorch 不一致。6.4 验证系统可靠性用录制视频回放代替实拍测试在正式验收前我习惯把监控视频录下来用回放模式反复测试预警逻辑。回放测试能暴露两件事一是“漏报率”——有些帧的吃水线检测置信度低于阈值导致没有触发预警二是“误报率”——水面反光或波纹被模型当成水线触发假预警。我会统计回放 30 分钟视频中两种事件的数量作为系统指标写入项目文档。如果漏报集中在夜间或逆光场景不要急着换模型先尝试在预处理阶段做简单的对比度增强和 CLAHE 均衡化有时效果比换更大的模型更明显。这也是一条经验吃水线检测的瓶颈往往不是模型容量而是图像质量本身。希望这套流程能帮你少走弯路。本文还有配套的精品资源点击获取