简介:一份基于YOLOv8的港口船舶吃水标尺自动读取项目,面向计算机、人工智能、自动化等专业的毕设或课程设计场景,解决港口场景下船舶吃水标尺读数自动识别问题。项目包含可直接运行的Python源码、可视化界面、完整数据集与部署说明,部署简单,测试通过后上传,可满足毕业设计答辩对功能完整性的要求。压缩包共8个文件,以3个Python脚本、3个模型权重文件及2个说明文档为主,整体体积约15.91MB;脚本分别对应模型训练、视频检测与可视化界面操作,权重文件可直接加载使用,说明文档帮助快速上手。资源内置训练输出指标的可视化能力,可生成核心指标曲线、混淆矩阵、F1分数曲线、精确率-召回率曲线、验证集预测结果及标签分布图,有助于分析模型效果并支撑论文与答辩展示。目前已有32人学习下载,适合需要快速搭建深度学习目标检测项目的学生或开发者。
1. 港口船舶吃水标尺自动读取:一个看着简单但细节吃人的YOLOv8项目
别人眼里「不就是识别几个数字嘛」的港口船舶吃水标尺自动读取,真做起来会发现难点根本不在数字识别,而在船体晃动、水面反光、海面杂物遮挡和标尺刻度分布不规律这些现实条件。基于YOLOv8做这件事,核心不是训练一个能框住数字的模型——那只是第一步——真正决定项目能不能交付的,是数据怎么标、读数怎么从框里算出来、界面怎么把结果稳定呈现。这个方向尤其适合船舶与港口方向的毕设或课程设计,YOLOv8源码、可视化界面、数据集和部署教程四个交付物正好对应了评审最关心的完整度。如果你只想找个能跑的现成项目交差,这篇帮不了你;如果你想搞清楚这个项目每一环怎么搭、参数为什么这么设、翻车了看哪里,可以往下读。
2. 构建吃水标尺数据集:从实拍采集到labelme标注的完整流水线
2.1 采集哪些场景的图:视角、光照与遮挡覆盖
港口船舶吃水标尺的数据采集,第一原则是「宁可少而全,不要多而偏」。常见做法是找港口监控视频、船侧照片或船舶进出港的公开影像,按三个维度去覆盖。
视角维度。吃水标尺通常刻在船艏或船艉两侧,实际拍摄时往往是斜视视角,标尺在画面里呈梯形畸变。我一般会按近距离平视、远距离俯视、侧向斜视三类各采一部分,比例大约在 4:3:3,让模型在不同视角下都能认识标尺。
光照维度。船舶进出港不分白天黑夜,数据集里如果全是晴天正午的照片,部署后会死得很惨。要覆盖逆光(太阳在船后方)、顺光、阴天漫反射、夜航灯光照明几种光照。实在采集不到夜航图,后期增强里要把曝光扰动和亮度抖动拉大,这部分在 2.4 节展开。
遮挡维度。标尺周围经常有缆绳、舷梯、海面波浪甚至其他船舶的船体遮挡,采集时尽量多保留这类「不完美样本」。一个我踩过的坑是初期只采干净图,模型在真实视频里遇到水波纹把刻度盖住一半的情况直接失灵。
2.2 labelme标注的矩形框规范:不同类别怎么分配
类别设计是数据这章最容易做错的一步。常见做法是设三类:标尺刻度数字、水面线、标尺参照区域。其中刻度数字类别有两种路线,一是按 0 到 9 单类别收集,二是按数字实际含义分 10 个类。我一般推荐后者,因为吃水读数是按具体数字算的,分 10 类可以直接拿到读数语义,代价是每类样本量要足够。
标注工具用 labelme,这是将 labelme 标注用于 YOLOv8 训练最成熟的流程。标注规范有几条硬性约定:
数字框要紧贴数字本体,不包含刻度线、不包含数字底下的白色漆面背景。目标是让模型学到「数字的形状」,不是「刻度的位置」。
水面线类别标注为细长矩形,紧贴水与船侧的交界线。这条线的框高度不要超过 8 到 10 个像素,宽度覆盖可见标尺范围即可。水面线框质量直接决定后续吃水值的像素换算精度,值得花时间逐张精修。
标尺参照区域是一个大框,把整段可见标尺包进去,用于后期计算每像素对应的实际高度。这个类别如果你的场景里标尺区域总是完整可见就一定要建,如果常常被遮挡就把它去掉,避免模型在该框缺失时产生误检。
2.3 数据集目录组织与转换脚本:labelme JSON 转 YOLO TXT
YOLOv8 训练需要的是 txt 格式标注,而 labelme 输出的是 JSON 多边形或多边形转矩形后的 JSON,中间需要一个转换步骤。数据集目录我一般这样组织:
dataset/ ├── images/ │ ├── train/ │ ├── val/ │ └── test/ ├── labels/ │ ├── train/ │ ├── val/ │ └── test/ └── data.yaml转换脚本用 Python 写,核心逻辑是把 labelme 的 JSON 中的 points 转成归一化的 x_center、y_center、width、height。
import json import os from glob import glob # 类别名与ID的映射,顺序要和之后 data.yaml 里 names 完全一致 class_mapping = { "0": 0, "1": 1, "2": 2, "3": 3, "4": 4, "5": 5, "6": 6, "7": 7, "8": 8, "9": 9, "waterline": 10, "scale": 11 } def convert_labelme_json(json_path, output_txt_path, img_width, img_height): with open(json_path, "r", encoding="utf-8") as f: data = json.load(f) lines = [] for shape in data["shapes"]: label = shape["label"] if label not in class_mapping: continue # 忽略未定义类别 points = shape["points"] # labelme矩形是两个对角点[[x1,y1],[x2,y2]] x1, y1 = points[0] x2, y2 = points[1] # 确保x1<x2, y1<y2 x1, x2 = min(x1, x2), max(x1, x2) y1, y2 = min(y1, y2), max(y1, y2) x_center = (x1 + x2) / 2 / img_width y_center = (y1 + y2) / 2 / img_height w = (x2 - x1) / img_width h = (y2 - y1) / img_height # YOLO格式: class_id x_center y_center width height(均归一化) lines.append(f"{class_mapping[label]} {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}") with open(output_txt_path, "w", encoding="utf-8") as f: f.write("\n".join(lines)) # 批量转换:每个images/train下的jpg对应一个同名json for img_path in glob("images/train/*.jpg"): json_path = img_path.replace(".jpg", ".json") txt_path = img_path.replace("images/train", "labels/train").replace(".jpg", ".txt") if os.path.exists(json_path): # 实际使用时从图片读宽高,这里演示用固定值 convert_labelme_json(json_path, txt_path, 1280, 720)这段脚本的逻辑是:读取 labelme 的 JSON,遍历每个 shape,把矩形对角点转成中心点加宽高的归一化坐标,再写成 YOLO 格式的 txt。注意两个参数细节:归一化必须用图片实际宽高,不能写死;类别 id 顺序必须和后续 data.yaml 中 names 的索引完全一致,否则训练时类别会错位。
转换完一定要抽查验证。用一个简单脚本读取 txt 画框回图片上,肉眼核对框是否贴合数字,这一步能避免标注数据和训练数据错位的隐性 bug。
2.4 数据增强:让模型在低光和水波纹下不翻车
吃水标尺检测的增强策略和常规目标检测不太一样。常规检测喜欢用 random crop 和 mosaic 提升泛化,但标尺是细长结构且数字密集排列,过度裁剪会让数字宽度只占几个像素,模型反而学不到特征。我常用的增强参数是:mosaic 关闭或只在前 10 个 epoch 开启(ultralytics 里 mosaic 是自动衰减的,但我还是建议小数据集直接关掉),然后手动加大光照类扰动。
字段是训练脚本里这样设的:
model.train( data="dataset/data.yaml", epochs=100, imgsz=640, hsv_h=0.015, # 色相扰动不要太大,船体颜色本身是重要上下文 hsv_s=0.5, # 饱和度可以大一点,模拟不同天气下的色偏 hsv_v=0.6, # 明度扰动放大,模拟逆光和夜间 degrees=10, # 小角度旋转,模拟船体晃动 translate=0.1, scale=0.4, flipud=0.0, # 垂直翻转禁止,数字倒过来会语义错乱 fliplr=0.5 )这里的核心思考是:flipud 一定要设为 0,因为数字 6 和 9 在垂直翻转后语义会互换,模型学到的特征会被严重干扰;fliplr 可以保留,因为水平镜像后数字本身只是位置变化,语义不变。hsv_v 加大是为了帮模型应对逆光场景里标尺区域整体偏暗或过曝的情况。如果数据集里有水波纹遮挡,我还会额外用 albumentations 库叠加随机波纹形变,但那是后话,不追求极致效果可以先不加。
3. 从零跑通YOLOv8训练:环境搭建、配置与超参数调整
3.1 搭建YOLOv8训练环境:CPU也能跑通,GTX 1660 Ti 也能训出能用的模型
标题里写「简单部署即可运行」,对环境的要求就不能高。当前 ultralytics 包已经不需要手动编译 darknet 或 pytorch 的 C 扩展,环境搭建顺手很多。我一般在 Ubuntu 20.04 上这样搭:
# 创建独立 python 环境,避免污染系统 python conda create -n yolo_draft python=3.10 conda activate yolo_draft # 安装 PyTorch,CPU 版直接走 pip 默认源即可 pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu # 安装 ultralytics,会自动带 YOLOv8 源码与推理工具 pip install ultralytics把 CPU 版 PyTorch 装好,再装 ultralytics 就能训练,只是慢。CPU 训练吃水标尺这种小数据集(几百张图)并不是不能接受,一个 epoch 可能要几分钟,总时长几小时看复杂度。如果是 NVIDIA 显卡,把第一行换成对应 CUDA 版本的 torch 安装命令即可,GTX 1660 Ti 跑 imgsz=640 的 YOLOv8s 大约能到 8 到 10 毫秒每帧的推理速度,训练一张图约 60 到 80 毫秒,完全够毕设节奏。
验证安装成功的命令是下面这条,它会下载一个官方预训练权重并跑通推理链路:
yolo predict model=yolov8n.pt source="测试图片.jpg"看到终端输出检测框坐标,说明环境已经通了。很多新手卡在「代码在哪里下载」,其实 yolov8 的源码与模型仓库都集成在 ultralytics 包中,不需要单独 clone 仓库,直接用 yolo 命令行或 python API 就行。
3.2 理解YOLOv8网络结构:知道你在调什么
训练前值得花十分钟理解 yolov8 网络结构图,至少知道三个核心部分在干什么。Backbone 用的是 C2f 结构(CSP 的改进版本),负责提取多尺度特征,C2f 层数越多特征越丰富,代价是计算量上升。Neck 是 PAN-FPN 结构,把高层语义信息与底层细节信息融合,这对标尺数字这种小目标很关键,因为数字通常只有 20 乘 30 像素左右,底层特征如果没传上来,模型根本看不见。Head 是解耦头,分类分支和回归分支分开输出,相对之前 YOLOv5 的耦合头收敛更快。
调参时要知道的对应关系是:遇到小目标漏检,优先改的是 Backbone 的深度倍率和 Neck 的融合方式(ultralytics 里通过 scale 参数间接控制);遇到框不准,优先关注回归分支的损失权重;遇到类别混淆,关注分类分支。实际项目里 90% 的情况不需要动网络结构,改输入分辨率和数据就够了,理解结构更多是为了和评审解释「为什么选 YOLOv8 而不是 YOLOv5」。
3.3 训练自己的数据集:配置 data.yaml 与启动命令
训练前需要写 data.yaml,这是 yolov8 训练自己的数据集最核心的配置文件。路径建议写绝对路径,省去相对路径带来的配置复制难题。
# dataset/data.yaml path: /home/user/dataset # 数据集根目录,绝对路径,不建议用相对路径 train: images/train val: images/val test: images/test nc: 12 # 类别数:数字0-9 + waterline + scale names: 0: "0" 1: "1" 2: "2" 3: "3" 4: "4" 5: "5" 6: "6" 7: "7" 8: "8" 9: "9" 10: "waterline" 11: "scale"启动训练的命令:
yolo detect train \ model=yolov8s.pt \ data=dataset/data.yaml \ epochs=120 \ imgsz=640 \ batch=16 \ patience=20 \ lr0=0.01 \ seed=42 \ project=runs/detect \ name=draft_v1参数说明:model 用 yolov8s.pt 作为预训练权重,而不是从头训练;s 版本是精度与速度的平衡点,n 太小容易漏检小目标,m 在 CPU 上训练太慢。epochs=120 对几百张图的数据集足够,再多容易过拟合。batch=16 是 8G 显存的安全值,显存不够就降到 8,对应学习率也要适当降低。patience=20 表示验证集指标连续 20 个 epoch 不提升就提前 stop,这是防止后期过拟合的后悔药。seed=42 保重现性,毕设答辩时别人问「你这个结果能复现吗」,有 seed 你不会心虚。
3.4 损失曲线怎么读:过拟合与欠拟合的判断
训练结束后,在 runs/detect/draft_v1 目录下会生成 results.png,包含 box_loss、cls_loss、dfl_loss 和验证集的 mAP 曲线。很多初学者只看 mAP,这是不够的,三个损失要分开看。
box_loss 持续下降说明回归分支在学习,也就是框的位置在变准。如果 box_loss 到了某个平台不再下降,但 val mAP 还在涨,说明精度提升来自分类分支的改善,框的大小可能还有系统性偏差,这时可以微调 anchor 或者增大 imgsz。
cls_loss 是分类分支损失,吃水标尺数据集里最容易出现的情况是 cls_loss 前期降得很快,后期开始反弹——这说明模型开始死记训练集的分布,典型的过拟合信号。此时看 conf 矩阵,会发现个别类别互相混淆,比如 8 和 9、3 和 5。
dfl_loss 是分布焦点损失,负责框边缘的精细回归,对小目标尤其重要。吃水标尺数字框高度普遍在 20 像素上下,dfl_loss 降不下去通常意味着小目标特征不够,优先方案是提升 imgsz 到 768 或 832,而不是盲目加深网络。
3.5 导出权重:pt 与 ONNX 两种格式什么时候用
训练完的 best.pt 可以直接用于后续的 PyQt5 界面加载,这是最省事的路径。但如果部署环境没有 PyTorch,或者想用 ONNXRuntime 加速 CPU 推理,需要导出 ONNX:
yolo export model=runs/detect/draft_v1/weights/best.pt format=onnx imgsz=640 opset=12导出时有两个坑。第一,imgsz 必须和训练时一致,否则推理结果会明显变差;我见过不少人训练用 640、导出写 1280,结果模型在真实图片上框全偏了。第二,opset=12 是兼容性最好的版本,新版 opset 某些算子对 onnxruntime 的 CPU 实现不友好。导出的 ONNX 用 onnxruntime 跑,推理速度在纯 CPU 上比 PyTorch 快 20% 到 30%,如果你的部署机没有显卡,值得用 ONNX。
4. 部署与可视化界面:把模型装进PyQt5桌面工具,从单张识别到自动读取
4.1 PyQt5界面最小骨架:选图按钮、显示区、读数结果区
可视化界面是毕设项目的门面,PyQt5 是最常见的选择,结构简单、打包容易。下面是一个能跑通的最小界面骨架,包含一个图片显示区、一个选择图片按钮和一个结果显示标签。
import sys from PyQt5.QtWidgets import QApplication, QMainWindow, QPushButton, QLabel, QFileDialog, QVBoxLayout, QWidget from PyQt5.QtGui import QPixmap class DraftApp(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("港口船舶吃水标尺自动读取") self.setMinimumSize(900, 700) self.image_label = QLabel("请选择图片") self.image_label.setMinimumSize(840, 520) self.result_label = QLabel("吃水值:--") self.btn_open = QPushButton("选择图片") self.btn_open.clicked.connect(self.open_image) layout = QVBoxLayout() layout.addWidget(self.image_label) layout.addWidget(self.result_label) layout.addWidget(self.btn_open) container = QWidget() container.setLayout(layout) self.setCentralWidget(container) def open_image(self): path, _ = QFileDialog.getOpenFileName(self, "选择图片", "", "Images (*.jpg *.png *.bmp)") if path: self.image_label.setPixmap(QPixmap(path)) self.run_inference(path) if __name__ == "__main__": app = QApplication(sys.argv) window = DraftApp() window.show() sys.exit(app.exec_())这个骨架的逻辑很清楚:按钮触发文件对话框,选中图片后显示在 QLabel 上,同时调用 run_inference 做推理。核心是 run_inference 方法里怎么把检测结果转成吃水值。
4.2 吃水标尺自动读取的计算逻辑:数字框最低点与水线框的换算
这是整个项目技术含量最高的一步。检测模型输出的是数字框、水面线框和标尺区域框,但它们一共只回答了几个问题:数字在哪里、水线在哪里、标尺范围在哪里。真正的吃水值需要手动计算。
思路是:标尺区域框的上下边缘对应一个已知的实际高度范围(这个范围通常在船体设计图中标记,或通过相邻数字的间距推算),把标尺框内的像素高度映射到实际厘米数,然后找水面线框的上边缘和最近数字框的最低点,用线性插值算出当前吃水值。
import cv2 from ultralytics import YOLO # 加载训练好的权重 model = YOLO("runs/detect/draft_v1/weights/best.pt") # 标尺区域的物理范围,单位cm # 实际场景中从标尺上读取相邻两个数字的差值获得 SCALE_RANGE_CM = 200.0 def read_draft_value(results, img_height): # results 是模型推理返回的对象,先取检测框的类别和坐标 boxes = results[0].boxes if boxes is None: return None digit_boxes = [] # 数字框 [x1, y1, x2, y2, cls_id] waterline_box = None scale_box = None for box in boxes: cls_id = int(box.cls[0]) x1, y1, x2, y2 = [float(v) for v in box.xyxy[0]] if cls_id <= 9: # 0-9 是数字 digit_boxes.append((x1, y1, x2, y2, cls_id)) elif cls_id == 10: # waterline 类别 waterline_box = (x1, y1, x2, y2) elif cls_id == 11: # scale 类别 scale_box = (x1, y1, x2, y2) if waterline_box is None or scale_box is None: return None # 标尺区域的上边界和下边界对应的像素位置 scale_top = scale_box[1] scale_bottom = scale_box[3] scale_pixel_height = scale_bottom - scale_top if scale_pixel_height <= 0: return None # 像素到厘米的比例 cm_per_pixel = SCALE_RANGE_CM / scale_pixel_height # 找水面线上边缘的像素y坐标 waterline_y = waterline_box[1] # 找距离水面线最近的数字框,取它的最低点作为刻度读数参考 nearest_digit = min(digit_boxes, key=lambda d: abs(d[1] - waterline_y)) digit_bottom_y = nearest_digit[3] # 数字最低点与水面线的像素差,换算成厘米差 pixel_diff = waterline_y - digit_bottom_y value_diff = pixel_diff * cm_per_pixel # 数字框对应的实际值就是该类别的数字,加上差值得到最终吃水 digit_value = nearest_digit[4] draft_value = digit_value + value_diff / 100.0 # 厘米转米 return draft_value这段代码要注意几个边界条件。数字框和水线框可能因为船体晃动出现短暂缺失,所以前面要先判断 waterline_box 和 scale_box 是否为空。像素差 pixel_diff 可能为负,代表水线在参考数字框最低点之上,这在船体上浮时是正常现象,不要丢掉负号。SCALE_RANGE_CM 的取值直接影响最终数值的准确性,最常见做法是用检测到的相邻两个数字框的像素间距反推每像素厘米数,替代写死的标尺范围,这样能避免标尺区域检测框抖动带来的误差。
4.3 把单张识别扩展成视频流读取
毕设演示时视频流比单张图片更有冲击力。把界面里的 open_image 扩展成 open_video,用 OpenCV 逐帧读取,每帧做推理并在画面上绘制检测框和水线,实时刷新 QLabel 即可。
def run_video_inference(self, video_path): cap = cv2.VideoCapture(video_path) while cap.isOpened(): ret, frame = cap.read() if not ret: break # 推理 results = model(frame) annotated = results[0].plot() # ultralytics自带绘制结果 # 计算吃水值 draft_val = read_draft_value(results, frame.shape[0]) # OpenCV BGR转RGB,再转QPixmap显示到界面 rgb = cv2.cvtColor(annotated, cv2.COLOR_BGR2RGB) h, w, _ = rgb.shape img = QImage(rgb.data, w, h, 3 * w, QImage.Format_RGB888) self.image_label.setPixmap(QPixmap.fromImage(img)) # 更新读数标签 self.result_label.setText(f"当前吃水:{draft_val:.2f} m") # 控制帧率,避免界面卡死 cv2.waitKey(30) cap.release()这段代码里最值得说的是帧率控制。cv2.waitKey(30) 表示每帧间隔 30 毫秒,约 33 帧每秒的播放速度。如果推理本身耗时超过这个时间,实际帧率会被推理速度拖累,界面看起来卡,这时不要盲目降低 waitKey 值,而应该看每帧推理耗时。
5. 避坑:吃水标尺项目最容易翻车的5个点
5.1 标注时把刻度线也标进数字框,模型学成了检测「整条标尺」
这是数据阶段最高频的翻车。现象:训练时 loss 降得很快,但推理时数字框高矮参差不齐,把刻度和数字连在一起框出来,后续读数计算全部错位。原因:标注者为了省事,把数字连同底下的长刻度一起拉框,模型学到的特征是「白色长条形」而不是「数字轮廓」。解决:回头逐张检查标注,数字框上下边界紧贴数字的上下边缘,宁可少 1 到 2 个像素的留白,也不能多包含刻度线。经验法则是框的高度应该在 15 到 25 像素左右,如果出现 60 像素高的数字框,基本就是标错了。
5.2 数据集全部来自白天,模型在逆光场景下全军覆没
现象:验证集 mAP 有 0.85,拿傍晚逆光的视频一测,数字框全丢,水面线框倒是还在。原因:训练集中几乎没有暗光样本,模型把「亮背景 + 深色数字」当成了数字的必要特征。解决:采集阶段尽量掺入不同时段的样本;增强阶段把 hsv_v 调大到 0.6 以上,让模型见过极端明暗变化。还有一个土办法:对现有数据集做灰度化和伽马变换生成额外训练样本,把每张图复制一份加入训练集,虽然土但有效。
5.3 学习率设得太大,训练第 10 个 epoch 损失直接变 NaN
现象:loss 曲线在前几个 epoch 正常下降,突然变成 nan,best.pt 不再更新。原因:学习率太大导致梯度爆炸,或者 batch 里混入了全黑、全白的异常图片。解决:先恢复默认 lr0=0.01 重跑,排除参数问题;然后检查训练集里是否有损坏图片或标注越界(比如归一化坐标超过 1 的框)。我遇到过一张标注文件里出现了 x_center=1.8 的情况,就是 labelme 转换脚本没做边界裁剪导致的。
5.4 数字类别样本不均衡,0 和 9 互相误检
现象:confusion matrix 里 0 被识别成 8、9 被识别成 7 的比例明显偏高。原因:船舶吃水标尺的数字通常出现在 0 到 9 之间但分布不均,比如常见吃水深度 6 到 12 米时,6、7、8 出现频率远高于 0、1。解决:先统计每类样本数,把少于平均数的类别做复制粘贴增强,也就是把该类别的框连同小图块复制到其他图片的随机位置。注意复制时不要覆盖原有数字框,粘贴区域选在标尺区域附近要保持光照一致性。
5.5 导出 ONNX 后推理结果和 PyTorch 完全不一致
现象:同一个测试图,best.pt 识别出 7 个数字框,改 ONNX 后只出来 1 个框,而且坐标明显不对。原因:导出时 imgsz 和推理时的 imgsz 不一致;或者 ONNXRuntime 的输入张量是 NCHW,你喂了 HWC。解决:固定导出的 imgsz 为 640,推理时原图先缩放再 pad 到 640 正方形,pad 值用 114(YOLO 默认填充色)。如果你要用 ONNXRuntime,推理代码里要自己补上 letterbox 预处理,不要直接用 cv2.resize 硬拉伸。
6. 进阶:用多帧投票把读数从「能识别」做到「能交付」
视频流场景里常见的尴尬是:模型每一帧都检测成功了,但读数在 0.3 米范围内来回跳,看起来非常不可信。原因在于船体晃动导致水面线框在帧间抖动,加上数字框边缘预测的不确定性,单帧换算出来的吃水值天然带噪声。这里有一个不引入新依赖就能见效的技巧——指数移动平均。
EMA 的思路是给最近的读数更大的权重,而不是把历史读数简单平均。它对突发的单帧误检有天然抑制作用,实现成本只有几行代码。
class DraftSmoother: def __init__(self, alpha=0.3): self.alpha = alpha self.ema_value = None def update(self, new_value): if new_value is None: return self.ema_value if self.ema_value is None: self.ema_value = new_value else: self.ema_value = self.alpha * new_value + (1 - self.alpha) * self.ema_value return self.ema_value def reset(self): self.ema_value = Nonealpha 的取值很讲究。alpha 越大,响应越快,但噪声也越大;alpha 越小,曲线越平滑,但对真实吃水变化的响应会滞后。我做测试时 alpha=0.3 到 0.4 是比较好的平衡点。alpha=0.3 意味着当前帧只有 30% 的贡献,历史 70% 决定当前值,这样的平滑性足够让读数在视频里稳定到小数点后两位不跳动。如果船进出港过程中吃水是快速变化的,酌情把 alpha 调到 0.5,否则读数的响应速度会显得「粘住不动」。
用的时候有个细节:当某帧检测不到水面线框时,update(None) 会保留上一次的 EMA 值而不是变成空值。这样短暂的水波纹遮挡不会导致读数瞬间清零,比「丢帧就显示无效」的用户体验好得多。
再进一步,如果你有时间,可以在标尺区域检测框稳定后做一次单应性校正,把斜视的标尺区域投影成正视的矩形,再在正视图像上做数字识别和吃水换算。这一步能显著提升斜视角下的读数精度,但要小心校正时插值会模糊掉小数字的细节,数字框需要在校正前的原图上检测、校正后再做换算。我现在的习惯是把这步留给已经跑通基础流程、想冲高精度的场景,不要一开始就做,否则会陷入调参泥潭。
吃水标尺自动读取做到这一步,已经能从「检测出数字」进化到「连续输出稳定吃水值」,这也正是这个方向比普通目标检测项目更能体现工程能力的地方。当初我自己做的时候最深的感受是:模型训练只是整个项目里最简单的一环,数据规范和后处理才是真正拉开差距的部分。希望这篇能帮你在毕设或课程设计里少走几个我没绕开的弯。
本文还有配套的精品资源,点击获取