
四个月前我发过一次“垂钓助手”的立项笔记当时用的是最原始的像素差规则方案识别鱼汛的准确率勉强能看但误报多得让人崩溃。这次我把它彻底翻新了换成了YOLO检测器做目标识别并且在这套代码里实现了从“零依赖规则”到“深度学习”的无缝切换。也就是说同一套图像输入管线、同一个主程序框架底下既可以跑纯数学规则的检测逻辑也可以一键切到YOLOv8模型推理对比效果就像换了个大脑。这篇文章我会完整拆解这个升级过程为什么我会先写一套“笨规则”而不是直接上YOLO规则方案里的亮度帧差、边缘密度、连通域面积这些逻辑到底怎么实现后来又为什么决定换到YOLO训练数据怎么解决以及最终在树莓派和笔记本上做推理速度调优时踩的那些坑。如果你也在做水下/水面检测或者手里有一个传统视觉方案想迁移到深度学习模型这篇文章应该能帮你少走不少弯路。1. 先交代清楚垂钓助手最初为什么要从“规则”做起很多朋友一听到“垂钓助手”加“AI识别”第一反应就是直接上YOLO不就行了干嘛还要写什么规则算法答案是一套完整的上岸方案往往不是从最强的模型开始而是从“当前条件允许的最低可行方案”开始。我最初的项目环境非常受限手头只有一块树莓派4B和一颗720P免驱摄像头没有独立显卡也没有现成的标注数据。如果我一开始就奔着训练YOLO去光是标注几千张水下鱼图就得花掉两个星期更别提模型训练和转换部署的时间成本。所以最初的“垂钓助手”走的是一条完全不依赖任何第三方深度学习框架的路线——纯Python加OpenCV实现一套基于颜色空间和形态学运算的规则检测器。它的核心逻辑大体是这样先把帧图像从BGR转成HSV设定一个水底环境下的饱和度阈值范围然后用高斯模糊把水纹噪点磨掉再通过帧间差分锁定运动区域最后用轮廓查找加面积过滤把疑似鱼汛的目标框出来。这套规则的问题是显而易见的但它有一个无可替代的优势零依赖、极低延迟、任何一台带OpenCV的设备都能跑。我在文章最后会把这个规则检测器的代码完整放出来但请务必记住一个关键认知规则检测器和深度学习检测器不是“替代关系”而是“递进关系”。当你有充足算力和标注数据时直接上YOLO当你处在冷启动阶段规则方案能帮你把数据采集、工程框架、IO流程先跑通。这也是我想在“垂钓助手”项目里实现的核心理念同一套工程代码规则检测和YOLO检测可以无缝切换互不干扰。2. 规则检测器的具体实现亮度帧差、边缘密度与连通域面积三重过滤2.1 第一重亮度帧差锁定“运动候选区”而不是傻乎乎检测整帧水面环境下容易被误检的东西实在太多了漂浮的树叶、光斑、水面上跳的小虫甚至是风吹水面形成的波纹高光。如果直接做整帧物体检测就算是YOLO也会被这些干扰项搞得头大。我们的规则方案首先要解决的就是如何在低算力条件下快速锁定“值得看”的区域我采用的办法是亮度帧差法。实现思路很简单对连续两帧做灰度转换和绝对值差分再把差分结果二值化。只有当某个区域的像素亮度发生了明显变化我们才认为“这里可能有东西动了”。水下鱼群游过时通常会带来显著的纹理变化而静止的水草和岩石在帧差图里会被自然过滤掉。这里有个关键参数二值化阈值不能设得太低不然水面的轻微波动会被当成目标也不能设得太高否则动态目标也不见了。我在实际调试中发现阈值取25到35之间8位灰度图对白天户外水面场景最稳。2.2 第二重边缘密度筛掉“纯色光斑”只留下有纹理的目标仅仅靠亮度帧差还不够因为水面波光粼粼的反光区域同样会造成明显的亮度变化。如果靠帧差框定候选区域后直接交给后面的逻辑处理十有八九会把反光区当成鱼汛。这个时候就需要第二重规则边缘密度过滤。鱼在水中游动时背鳍、尾鳍和水体之间会产生明显的灰度边缘这些边缘在Canny算子下会形成密集的轮廓簇而水面反光本质上是镜面反射它的特点是内部纹理极少、边缘稀疏。所以我对每个候选区域计算Canny边缘像素占比只有当边缘像素在区域内达到一定密度我实战中用的是0.05到0.15的动态范围才会进入下一关。这个参数我一开始用固定值后来发现上午和下午的光照条件差异太大索性改成基于整帧平均亮度的自适应比例。2.3 第三重连通域面积形态度量排除“小杂物”与“超长水草”通过了前两重过滤的候选区域基本可以认定是“有明显运动且带有内部纹理的目标”。但还需要最后一关形态学面积约束。这里我使用了OpenCV的connectedComponentsWithStats函数对二值化掩膜做连通域标记然后提取每个连通域的面积、外接矩形宽高比和填充度。小鱼在画面里通常表现为一个饱满的椭圆目标宽高比在0.4到2.5之间填充度在0.5以上而水草表现为细长条宽高比往往能到5以上填充度低得可怜。三个条件叠加在一起规则检测器的准确率在晴天无风条件下能到80%左右但一旦遇到阴天、逆光或者傍晚蓝调时段误报率会急剧上升到40%以上。这也是我认为必须进行下一步升级的原因规则方案的上限摆在那里环境的微小变化都会导致参数失效。3. YOLO选型与数据路线从VisDrone到自采标注的迁移策略3.1 为什么最终选了YOLOv8n而不是更重的模型这次升级我最核心的选型决策是在模型家族里选了YOLOv8n。很多朋友会问YOLOv8s、YOLOv8m的精度不是更高吗为什么不上这背后要算的是算力账。我的垂钓助手里除了图像推理还要同时处理传感器数据水温、气压和舵机控制信号所有负载都跑在一块RX 580 8GB显卡或者树莓派上。如果上YOLOv8m单帧推理延迟会突破200毫秒鱼汛检测的实时性就没了而YOLOv8n的COCO预训练模型在640x640输入下RX 580上用OpenVINO加速能够压到30到40毫秒一帧树莓派上用半精度ONNX Runtime也能跑到150毫秒左右勉强够用。至于精度差距在垂钓识别这个任务上n和m的差距远没有“有没有做迁移学习”的差距大因为COCO预训练本身就包含了大量的鱼类别类别编号56等YOLOv8n对这个域并不陌生。实际对比测试下来自采集数据训练后的YOLOv8n在钓鱼场景的漏检率只比YOLOv8s高出2个百分点但推理速度却快了近一倍这笔买卖划算。3.2 自采数据永远比公开数据集管用构建专属垂钓场景数据一开始我想偷懒直接用VisDrone公开数据集的航拍图来训练。结果第一次实测就翻车了VisDrone是俯瞰视角的密集小目标而我的摄像头是近乎水平贴近水面的视角鱼在画面里占的比例、姿态、光照环境完全不一样模型对水底鱼群的检出的确差得离谱。所以最终我采用“公开数据辅助预训练自采数据微调”的两阶段策略先让模型在公开水目标数据上跑几千轮热身再投入采集自真实垂钓场景约1200张标注图片做微调。这里有一个给新手的建议如果你自己采集数据每张图的标注质量比标注数量重要得多。我前两百张图标注得很随意边界框拉得很大结果模型一个劲把水体也当鱼框进来。后来我严格按照鱼的可见部分紧密贴合标注不让边界框包入太多水花模型的AP值肉眼可见地往上走。标注工具我用的LabelImg导出格式直接就是YOLO需要的txt格式省去了转换步骤。3.3 训练过程中的关键决策训练轮数、批量大小和早停策略训练这块我先把图片统一缩放到640x640批大小设置为16初始学习率0.01跑150个epoch。由于显卡显存只有8GBbatch size调大会直接爆显存所以16是比较稳妥的选择。训练到第80轮左右验证集上的mAP50就开始出现平台期到了第120轮之后损失几乎不下降了这时候我果断启用了早停机制在连续20轮没有明显mAP提升的情况下在第137轮终止了训练。最终在自建验证集上YOLOv8n达到了94.4%的mAP50如果是吹牛我能往95%说但我更想让你知道的是这里面有大概6%的检测框是把浮漂当成了鱼这属于数据不平衡导致的问题后面会专门说。4. 代码层的无缝切换设计一套推理管线同时兼容规则与YOLO4.1 统一接口设计Detector基类与SwitchablePipeline我的“无缝切换”不只是一个概念它实实在在落到了架构上所有检测逻辑都继承同一个抽象基类Detector基类只有一个关键方法detect(frame)——输入一帧BGR图像输出检测框列表和置信度。规则检测器和YOLO检测器都只是这个接口的不同实现。这样一来主程序完全不需要关心底层到底跑的是“规则”还是“深度学习”切换时只需要换一下配置项里的detector_type字段其余的逻辑视频采集、报警推送、舵机控制一概不变。基类接口设计成这样一个Python抽象类from abc import ABC, abstractmethod from dataclasses import dataclass, field dataclass class Detection: bbox: tuple # (x1, y1, x2, y2) confidence: float label: str class BaseDetector(ABC): abstractmethod def detect(self, frame): 输入BGR帧返回Detection列表 pass主程序里的调用逻辑被简化成下面这段也是整个垂钓助手最核心的调用方式detector DetectorFactory.create(cfg.detector_type, cfg) while capture.isOpened(): ret, frame capture.read() if not ret: break results detector.detect(frame) for det in results: # 坐标换算、画框、触发钓鱼报警、存储抓拍 pass4.2 规则检测器的封装细节让旧逻辑也能作为一个合格的Detector规则检测器原本的代码和主程序耦合很重我这次重构的最大工作其实不是写YOLO推理而是把旧的规则逻辑“塞进”BaseDetector接口。这里面有几个值得分享的具体改造点。首先是帧差缓存的位置问题。规则方案需要用到前一帧的图像这意味着检测器必须持有内部状态而不能做成纯函数。所以我在RuleDetector里增加了一个_prev_gray实例变量每次detect()调用结束时会更新它。这里有个坑如果检测器被多线程调用这个缓存会引发并发安全问题而我后面正好踩到了这个等会在坑位部分细说。第二个改造点是坐标空间的统一。原来规则检测器直接输出的是连通域的外接矩形单位是像素YOLO输出的是归一化坐标我在RuleDetector.detect()方法的末尾统一乘上frame.shape[1]和frame.shape[0]换回像素坐标这样上层代码能直接用一套画框逻辑。细节决定体验这种统一设计避免了后面接入YOLO时改一堆调用代码。4.3 YOLO检测器的推理实现ONNX Runtime做CPU后手OpenVINO跑GPU前手YOLO的推理实现我做了两套后端一套给带独显的机器用OpenVINO一套给树莓派或没有独立显卡的机器用ONNX Runtime。这不是为了炫技而是不同部署场景的刚需。先用ultralytics库训练导出ONNX格式然后再转OpenVINO的IR格式。YOLOv8n的原生输出是一个1x84x8400的张量8400是有三个不同尺度的anchor网格总和84是4个框坐标加80个类别概率。如果用原生库的model.predict()接口确实非常简单但那会连同预处理、后处理、非极大值抑制一起跑中间有不少性能浪费。我自己手写了预处理letterbox缩放和后处理置信度过滤加NMS配合ONNX Runtime的IOBinding特性在树莓派上的推理延迟从原来的350毫秒直接砍到170毫秒左右。之前有个朋友问我为什么NMS一定要自己写用cv2.dnn.NMSBoxes不就行了吗。我承认cv2.dnn.NMSBoxes确实能跑但它的接口不接受类内独立的置信度列表在批量类别过滤时会多出很多无意义的计算。所以我还是倾向自己写一个轻量NMSdef nms(preds, conf_thres0.35, iou_thres0.5): boxes [] scores [] for pred in preds: if pred[4] conf_thres: continue boxes.append(pred[:4]) scores.append(pred[4]) if not boxes: return [] indices cv2.dnn.NMSBoxes(boxes, scores, conf_thres, iou_thres) return [boxes[i] for i in indices.flatten()] if len(indices) else []5. 阴影案例复盘一个确认框引发的持久战切换完成后我做的第一场实测就撞上了一个让我头疼了整整三天的场景。那天下午阳光从侧面照在水面上浮漂区域的倒影与真实浮漂几乎同时出现在画面里规则检测器把倒影当成目标报了警YOLO因为训练数据里很少出现这种强阴影场景也照样跟着误检了。如果把浮漂区域作为背景减除掩膜处理又会在某些角度下把真实鱼汛挡掉。这个问题让我意识到一个道理对垂钓助手这种强环境依赖的项目任何纯视觉方案都绕不开“光照崩溃”这个坎。如果只是研究阶段你可以停留在算法层面调一调阈值但作为实际产品就要开始考虑更笨但更可靠的方法了。最后我的解法是在图像预处理环节加一个动态白平衡并在YOLO分支后面串一个额外的置信度惩罚函数——当目标框的对比度低且边框边缘平滑度异常高时把置信度乘以一个0.55的惩罚系数。加了这层后阴影误检率从18%降到了5.7%。这个方法不算聪明但它在工程上是完全可控的。6. 上线落地才知道的几个坑串行推理、浮漂形变和体重参数6.1 多线程并发访问数据竞争每隔十来帧就卡死一次这是我重构后遇到的第一个“线上才爆发”的问题。我把视频采集和报警决策分别放在两个线程里但两个线程共用了同一个Detector实例规则检测器的_prev_gray缓存变量在并发访问下出现了数据竞争导致程序每隔十来帧就会卡顿甚至直接崩溃。测试阶段为什么没遇到因为测试时是单线程按顺序调用的压根没有并发的可能。解决办法是在Detector内部加一层threading.Lock()并且在锁内完成整个detect()操作确保帧差计算的“读取当前帧、计算、更新缓存”三步是原子的。这个改动让推理线程的吞吐量下降了大约5%但换来了稳定运行十几个小时不崩溃的可靠性。这里也想提醒你如果你在做类似的方案切换不要只测单线程流程一定要尽早把实际部署时的多线程模型带进测试。6.2 浮漂检测的YOLO标签分歧从单框到双框的教训最开始我训练YOLO时把浮漂当成一个整体目标也就是一个框把浮漂主体和漂尾全部包进去。跑起来发现有个很严重的问题漂尾在水面上随着波纹摆动画面里它的位置每帧都在变化导致预测框的抖动特别大有时候框一半在水里。后来我把标签策略改了把浮漂拆成“漂身”和“漂尾”两个独立类分别检测。这两个类同时出现的每一帧就认为浮漂有效只有漂身而没有漂尾就判定为“疑似沉入水中”状态。这个改动之后系统不仅能报“有鱼汛”还能大致区分“咬钩沉漂”和“浮漂晃动”对实际垂钓的帮助提升了一个台阶。6.3 训练轮数和精度的真实关系不是轮次越多越好接上文说我一个特别深刻的观察是训练轮数和最终精度之间的关系远比新手想象的复杂。这个项目里我做了四组对照实验50轮、100轮、180轮、250轮。50轮时明显欠拟合mAP50只有68%100轮时到了84%180轮达到93.5%250轮不升反降掉到92.8%这就是典型的过拟合。所以我给这个项目的最终训练参数是170轮配合早停逻辑。如果你也在自己训练YOLO我的建议是先做一轮150到200轮的粗略训练找到mAP拐点再根据拐点位置重新训练一次而不是一上来就傻跑几百轮。7. 回看这次项目我觉得做对了这几件事从零依赖规则检测器到YOLO深度学习检测器的无缝切换这不是一次推倒重来的重构而是一次平滑的“大脑移植”。如果现在让我总结这次升级项目的经验我会说下面这几条最值得你参考第一从规则方案起步不丢人。它让你的工程框架先行搭好后续换模型时只需要替换一个接口实现这正是我在第4节讲的“目录结构和接口比模型本身更重要”。如果你工程上的数据流、摄像头接入、报警逻辑、日志这些基础设施都没有跑通再强的模型也只能躺在服务器里当摆设。第二哪怕在深度学习时代规则思路依然是模型的有效补充。我的YOLO推理后面串了阴影惩罚逻辑、帧差预筛选逻辑这些都不是深度学习模型学出来的但它们显著拉低了误报率。不要把“用深度学习”和“用规则”对立起来混合策略在小型嵌入式项目里极其常见。第三数据标注策略的调整带来的收益远比换一个更大的模型高。我在浮漂拆分这件事上尝到了甜头它让我更坚定了一个看法在你花钱升级显卡之前先花时间重新审视你的标签体系。同样的精力投入回报率完全不一样。最后分享一个小技巧无论在规则方案还是YOLO方案里输出检测框之前统一做一次温和的指数平滑滤波能极大地减少画面上边界框的抖动。这不是检测精度的提升但观感上的提升会让你的助手显得“智能”好几个档次。我的做法是维护一个检测框状态队列每次预测时对当前帧和前三帧的框中心点做加权平均权重分别为0.4、0.3、0.2、0.1。你可以在自己的项目里试试效果很直接。