尧图精选

用计算机视觉打造AI鱼缸:基于YOLO的鱼种识别与行为分析实战

🕒 发布时间:2026/9/18 4:05:55 📁 来源:尧图网络
1. 从“盯着鱼缸发呆”到立项MiroFish 想解决的三个真实问题养鱼这件事入门靠热情坚持下来靠的是耐心。我养了三年观赏鱼前两年还算从容后面开始频繁出差问题就来了明明出门前换好了水、调好了温度可每次回家总会发现几条状态不对的鱼要么缩在角落要么呼吸急促更有两次因为没人喂食活活饿死了一小缸宝莲灯。那段时间我试过视频监控买过定时投喂器但都只能用“被动记录”的方式看着鱼缸根本做不到“主动发现问题”。后来我想到一个方向现在的计算机视觉已经能识别宠物、农作物、车辆为什么不能识别水族箱里的鱼鱼不会开口说话但它的游动轨迹、呼吸频率、进食意愿其实都是肉眼可读的信号。只是人不可能 24 小时站在鱼缸前机器可以。这就是我做 MiroFish 的初衷给鱼缸装一双由 AI 驱动的“眼睛”让它帮我盯着水质之外那些更需要连续观察的生物指标。MiroFish 这个名字一半来自胡安·米罗。米罗的画作里全是流动的、抽象的、富有生命感的形态我觉得这和水里的鱼特别契合另一半就是 fish 本身。整个项目要做的事可拆成三件第一实时识别鱼缸里每条鱼知道“谁是谁”第二持续追踪鱼的游动状态计算活跃度、呼吸节奏和摄食行为形成健康评分第三把这些数据接到自动投喂器和手机通知上让设备根据鱼的实际情况决定什么时候喂、喂多少。这个工程跨了图像识别、边缘计算、嵌入式硬件和 Web 应用几个领域听起来复杂但思路一旦捋清每一步并不难。如果你也养鱼、也写过一点 Python或者对“摄像头 AI 模型 硬件联动”这套玩法感兴趣这篇文章会告诉你我从零到一踩过的坑和最终跑通的完整方案。1.1 传统监控方式的不足定时器和录像都治标不治本先说定时投喂器。市面上常见的自动喂食器核心就是一个定时马达到点转一下洒出饲料。它的逻辑是“我在固定时间喂了”但完全不管鱼吃没吃、吃了几口。我遇到过最尴尬的情况出差一周回来发现鱼缸水质浑浊几条鱼已经腹水原因就是喂食器到点了照常撒粮但那几天鱼本身状态不好根本不想吃过量饲料全部变成了污染源。再说视频监控。普通摄像头能录像但回看几十个小时的视频来观察鱼的状态基本不现实而且鱼的状态往往是在某一瞬间开始变化的等你发现异常再翻录像可能已经晚了。录像解决的是“事后取证”不是“事中预警”。1.2 MiroFish 的定位AI 眼睛 行为逻辑 硬件联动这套系统真正的价值不在于识别出“这是一条宝莲灯”而在于把识别结果变成可执行的管理动作。比如识别出鱼之后MiroFish 会持续跟踪这条鱼的游动轨迹如果某条鱼长时间躲在角落、游动距离明显低于平时系统会打上一个“活跃度异常”的标签再比如当投喂器准备投喂时系统会先检测鱼对饲料的响应情况如果鱼群根本没有聚集反应就取消本次投喂。也就是说MiroFish 不是一个“看得见”的摄像头而是一个“看得懂、会行动”的小机器人。它由三个子系统组成边缘推理子系统负责检测与跟踪行为分析子系统负责把轨迹变成指标投喂控制子系统负责把指标变成动作。2. 整体架构与选型为什么我选择了“边缘设备 YOLO FastAPI”这套组合2.1 先定硬件摄像头和处理设备怎么配MiroFish 首先要解决的是“用什么东西看鱼缸”。我在项目初期列过三套硬件方案最终选择的是“USB 摄像头 Jetson Nano 2GB”这套边缘组合。方案优点缺点适合场景PC NVIDIA 显卡训练和推理快调试方便功耗高、占地大、风扇噪音大开发测试Jetson Nano / Xavier NX功耗低、体积小、可长期运行算力有限需要模型轻量化正式部署手机 云服务零硬件成本随时随地延迟高、隐私风险、依赖网络临时监控摄像头选用的是罗技 C9201080P、30FPS驱动兼容性好色彩也相对真实。之所以没用树莓派官方摄像头模块是因为早期测试发现它在低光照下的噪点偏多而水族箱通常不能开太强的灯夜间更是只有月光灯这会让检测模型的效果下降。后来换成了 C920夜间配合 LED 补光灯效果提升非常明显。2.2 软件栈为什么是 Python YOLOv8 FastAPI软件层面的选择同样做过好几轮对比。最终定下的技术栈是这样的Python 3.10作为整个系统的主语言Ultralytics YOLOv8做目标检测和识别OpenCV负责视频流采集、预处理和画检测框ByteTrack做多目标跟踪给每一条鱼一个稳定的 IDFastAPI WebSocket把检测结果和实时画面推给 Web 端SQLite保存历史指标方便后续统计ESP32-C3 舵机做自动投喂硬件。这个组合最大的特点是“轻”除了 YOLO 推理稍微吃一点算力其余组件都可以跑在低功耗设备上。我没有选择 TensorFlow因为 YOLOv8 在边缘设备上的部署生态更成熟Ultralytics 自带导出到 TensorRT 的通道这对 Jetson 系列特别友好。2.3 不用传统图像处理的原因一开始我也尝试过用 OpenCV 的帧差法检测鱼毕竟“鱼在游动背景不动”看上去很适合做前景提取。结果在实际鱼缸环境里翻车很严重水面波纹会不断改变像素玻璃反光带来大面积亮斑鱼粪和饲料碎屑也会被当成移动目标。传统算法很难稳定地区分“鱼”和“水面噪声”。换成 YOLO 之后模型学的是鱼的形态特征而不是运动差异所以即使鱼静止不动、被水草遮挡、或者只露出半截身体也能给出相对稳定的检测框。这个差异决定了整套系统的可信度我需要的是“知道鱼在哪、知道这是什么鱼”而不是“知道哪里在动”。3. 鱼种识别与检测从数据集清洗到模型部署3.1 数据集从哪来自己录视频再抽帧标注训练识别模型第一步是数据。我养的主要是宝莲灯、三角灯、绿晶灯、几只珍珠鼠和一对神仙鱼市面上没有现成的“多鱼种淡水观赏鱼数据集”能直接套用所以我选择自己采集。具体操作流程是这样的用 C920 对着鱼缸连续录制 8 小时视频覆盖白天高光、傍晚、夜间月光灯等不同光照由于鱼游动很快录制帧率设为 30FPS但抽帧时隔 5 帧取一帧避免相邻帧过于相似导致数据冗余每 30 分钟抽一帧共获得大约 9600 张原始图用 labelImg 逐张标注每张图画一个或多个矩形框并标注类别名称剔除掉严重模糊、水草完全遮挡、鱼脱出水面的坏图。标注了一周眼睛都快花了但这一步非常值得。数据质量直接决定模型上限与其后面调试模型不如前面把标注做扎实。我总共标注了约 5200 张有效图片其中宝莲灯最多占了四成因为它是缸里的主力鱼群神仙鱼只有两条标注量最少。为了不让模型对神仙鱼“视而不见”我又额外补拍了一段神仙鱼在鱼缸前半区域活动的素材人为拉高了这类样本的比例。3.2 数据增强模拟鱼缸里的各种“刁钻视角”鱼不是模特不会乖乖正对着镜头。它们会侧身、翻转、快速扭头游到玻璃边缘时还会被曲率扭曲。为了让模型适应这些情况我在训练时开了 YOLOv8 内置的增强策略并按需调整Mosaic 增强把 4 张图拼成一张让模型学会在小目标占比较小时依然能识别随机透视与旋转模拟鱼在不同角度游动HSV 增强轻微改变色相和饱和度模拟不同灯光下的颜色偏移平移和缩放模拟鱼从远处游近、从画面边缘进入等场景。这里有个容易忽略的点增强不是开得越猛越好。曾试过把旋转角度开到 90 度结果模型把“倒置的鱼”也当成正常采样实际识别时反而更容易漏检。最终旋转角度我控制在 15 度以内。3.3 训练与调参跑通 YOLOv8 的命令我用的是 YOLOv8n 这个轻量级版本因为 Jetson Nano 的算力有限模型太大跑不动实时推理。训练命令如下yolo detect train \ data/home/mirofish/data.yaml \ modelyolov8n.pt \ epochs100 \ imgsz640 \ batch16 \ patience15 \ device0data.yaml 里写清楚训练集、验证集路径以及类别列表。训练在 PC 上进行RTX 3060 跑完 100 轮大约需要 4 小时。最终在验证集上的指标是 mAP50 0.914mAP50-95 0.683。对于 7 个鱼种的小规模任务这个精度已经够用。部署到 Jetson 上的时候记得做一步导出yolo export modelbest.pt formatengine device0导成 TensorRT engine 格式后推理速度从约 480ms 每帧降到 90ms 左右基本达到实时要求。不用 TensorRT 的话2GB 版本的 Jetson Nano 很难流畅跑 YOLOv8n。3.4 部署端推理代码部署端我用的是 OpenCV 读取视频流把帧送给模型推理再把检测结果画回原图import cv2 from ultralytics import YOLO model YOLO(/home/mirofish/weights/best.engine) cap cv2.VideoCapture(0) while cap.isOpened(): ret, frame cap.read() if not ret: break results model.predict(frame, imgsz640, conf0.45, verboseFalse) for r in results: boxes r.boxes.xyxy.cpu().numpy() clss r.boxes.cls.cpu().numpy() for box, cls in zip(boxes, clss): x1, y1, x2, y2 map(int, box) label f{model.names[int(cls)]} cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(frame, label, (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) cv2.imshow(MiroFish, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()conf 阈值我设置在 0.45。设置太低会出现大量误检把饲料颗粒、水草叶片都当成鱼设置太高又容易漏掉被遮挡的鱼。这个值最好根据你的鱼缸情况实测调整不要照搬别人的参数。4. 行为分析从“认出它是谁”到“知道它在干什么”4.1 多目标跟踪为什么必须给鱼一个 ID检测模型只能回答“这一帧里哪里有鱼”回答不了“这条鱼两秒前在哪”。为了分析游动行为必须做目标跟踪把同一个鱼缸里的同一条鱼关联到同一个 ID 上。我用了 ByteTrack。相比 DeepSORTByteTrack 不需要单独训练外观特征模型对算力的要求低很多在 Jetson 上跑也更稳。做法是直接用 YOLO 的检测框作为输入给 ByteTrack 更新轨迹然后从轨迹里提取每条鱼的运动特征。跟踪的难点在于鱼缸里的鱼形态相似尤其是宝莲灯和三角灯颜色和大小都很接近。ByteTrack 在鱼群快速交叉时会短暂丢 ID实测大概每 10 分钟会出现 2 到 3 次。对于行为分析来说这种短时间断档可以接受算法会自动衔接两条轨迹。4.2 活跃度计算把“活蹦乱跳”量化成数字我定义了一个简洁的活跃度指标单位时间内鱼的中心点移动总距离。import numpy as np def compute_activity(positions, fps30): if len(positions) 2: return 0.0 distances [] for i in range(1, len(positions)): dx positions[i][0] - positions[i-1][0] dy positions[i][1] - positions[i-1][1] distances.append(np.hypot(dx, dy)) return float(np.mean(distances[-fps * 60:]))这个函数返回最近 1 分钟的平均移动距离。正常状态下宝莲灯一分钟的移动距离在 800 到 1500 像素之间。当鱼生病或压力过大时它们在角落原地摆动这个值会掉到 200 以下。再加上一个静止时间统计当某条鱼持续 5 分钟中心点几乎没有位移时MiroFish 就会标记“疑似状态异常”。这里有一个我后来才意识到的问题像素距离受摄像头安装高度和鱼缸大小的影响很大。同一个鱼缸摄像头装得太远一条健康的鱼也可能只有 300 像素的位移。所以在部署初期我先让系统以“自学习模式”运行两天收集每条鱼在正常状态下的活跃度分布再以这个分布为基线做判断。这样比写死一个固定阈值靠谱得多。4.3 浮头识别一个很便宜但很有效的判据养鱼的人最怕“浮头”也就是鱼频繁游到水面吞咽空气这往往是缺氧的前兆。识别浮头不需要复杂模型只需要把画面分成上下两个区域统计“检测框中心点出现在画面顶部 20% 区域的次数比例”。当这个比例在一小时内超过 35%系统就认为存在浮头风险自动推送提醒。这个规则简单到几乎不算 AI但非常可靠。它的原理在于正常的鱼不会长时间聚集在水面呼吸而浮头是一个持续性和群体性的行为用统计比例就可以滤掉偶然经过水面觅食的个例。我实测过一段 20 分钟的缺氧视频MiroFish 在鱼群开始浮头后约 8 分钟触发了告警比我用肉眼发现早了至少 3 分钟。4.4 进食意愿判断喂食器要怎么感知“鱼饿不饿”自动投喂不能只靠定时器那样和市面几十块钱的投喂器没有任何区别。MiroFish 做的是“进食意愿感知”在投喂点附近划定一个兴趣区域投喂器释放少量饲料后统计 10 秒内进入该区域的鱼的数量和停留时间。如果 10 秒内至少有 3 条鱼进入投喂区域并停留超过 5 秒说明鱼愿意吃就继续投放下一批如果检测不到明显的进食反应就停止投喂并记录日志。这个机制有效避免了“鱼生病没胃口投喂器还在拼命撒粮”的情况。这个逻辑听上去简单但实现时有个细节兴趣区域的边界要稍微画大一点不能刚好贴着投喂点。因为鱼闻到饲料味之后会先在附近转圈再冲进去抢食如果区域太小会把这种情况误判成“无反应”。我调整了两次 ROI 范围最终定在投喂点为中心、向外扩展 100 像素的矩形区域。5. 自动投喂器的 ESP32 联动协议设计与断电安全5.1 电机与控制板喂食器怎么装自动投喂部分我用了 ESP32-C3 和一个 SG90 舵机。ESP32 负责联网和接收指令舵机负责转动饲料仓里的拨片。饲料仓我用一个透明的塑料瓶改造出料口大小需要反复测试保证每次转动只掉出约 0.1 克饲料。接线非常简单SG90 的电源线连 ESP32 的 5V地线共地信号线接 GPIO5。注意不要把舵机电源直接接到 ESP32 的 3.3V 引脚舵机启动瞬间电流较大会导致板子复位看起来像是程序崩溃。我第一次就踩了这个坑后来单独给舵机供 5V 电源才稳定。5.2 控制协议我用 HTTP API 而不是串口有人可能会问为什么不直接让 Jetson 用串口控制 ESP32原因很简单这两台设备在物理上可能相距很远串口线不好拉而且只要线一断就必须手动恢复。HTTP API 则没有这个限制Jetson 只要在局域网内就能发指令。ESP32 上跑了一个微 Web 服务核心逻辑如下#include WiFi.h #include WebServer.h #include ESP32Servo.h Servo servo; WebServer server(80); void feed() { servo.write(180); delay(300); servo.write(0); delay(200); server.send(200, text/plain, fed); } void setup() { WiFi.begin(MiroFishNet, your_password); while (WiFi.status() ! WL_CONNECTED) delay(500); servo.attach(5); server.on(/feed, HTTP_GET, feed); server.begin(); } void loop() { server.handleClient(); }Jetson 端通过 requests 库触发投喂时只需要一句话import requests resp requests.get(http://192.168.1.110/feed, timeout5) print(resp.status_code)5.3 断网与断电最担心的事必须有兜底自动投喂器最恐怖的事故是“卡粮后疯狂转动”或者“断线后重复投喂”。我做了两层防护第一层是 ESP32 端限制最小调用间隔。服务端收到/feed请求后会先检查距上次投喂是否超过 120 秒不满足直接拒绝。第二层是 Jetson 端的健康检查每 30 秒探测一次 ESP32 的/health接口连续 3 次不通就标记喂食器离线并认为“此时鱼缸状态未知”不再发出任何投喂指令。为什么离线时反而要禁止投喂因为当 AI 和投喂器失去联系时我们无法获取鱼的进食反馈任何自动投喂都可能是在向一条状态未知的鱼缸盲目抛粮。保守策略比激进策略安全得多。5.4 投喂量与时间表的校准过程刚开始我只配置了每天 3 次投喂时间分别是 9:00、13:00、18:00。跑了两周之后发现早上 9 点鱼群进食意愿并不高因为鱼缸位置靠近阳台上午阳光直射让水温升高鱼更倾向于躲在水草阴影里。我把早上的投喂时间挪到了 10:30反应明显好了很多。校准投喂量时我采用的是“增量为 0”的原则从每次转动一个档位开始观察 30 分钟。如果鱼群能在 5 分钟内把所有饲料吃完说明量偏少第二天增加一个档位如果 15 分钟后还有残饵说明量偏多就减少。这样反复调整一周就能找到一个相对精准的投喂量。6. 可视化仪表板与消息推送把鱼缸状态变成能随时查看的页面6.1 FastAPI 后端从模型输出到 Web 数据为了让用户不打开神秘的黑窗口也能看到鱼缸状态我写了一个 FastAPI 后端提供三类接口/api/fish返回当前画面中检测到的鱼种、数量、位置/api/health返回每条鱼的活跃度以及整体健康评分/api/feed接受手动投喂请求。这些接口本身不复杂但有一个细节值得注意实时检测是独立的进程Web 服务进程通过内存队列或 Redis 读取最新结果。不要把模型初始化和 HTTP 请求处理放在同一个进程里否则视频流推理会阻塞 API 响应交互体验非常差。我用的方案是 Python 的 multiprocessing 加一个共享队列推理进程把检测结果和轨迹数据丢进队列FastAPI 进程从队列里取。这样就算 Web 端同时有多个请求推理也不会被阻塞。6.2 前端页面不追求花哨追求一屏看懂前端我用的是原生 HTML JavaScript加上一个简单的 MJPEG 视频流标签。页面分成左右两栏左边是实时视频流显示检测框和鱼种标签右边是状态卡片显示当前水温、活跃度曲线、最近 24 小时异常事件数、下一次预计投喂时间。没有引入 Vue 或 React因为这个项目的核心价值在 AI 识别和联动逻辑而不在复杂的前端交互。单页静态文件由 FastAPI 直接托管代码量小、维护成本低。水温数据不是我额外加的传感器而是从鱼缸自带的加热棒控制器的串口读出来的通过另一个串口转 WiFi 模块发到后端。如果你没有这个条件也可以直接在后端写一个手动录入温度的接口把温度值存进 SQLite前端照样能画曲线。6.3 消息推送用 Server酱把异常发到手机微信光有网页还不够异常状态最好能主动推送到手机。我用的是 Server酱的 HTTP 接口只需要一个 SendKey 就能把消息发到微信import requests def push_alert(title, body): url https://sctapi.ftqq.com/YOUR_SENDKEY.send data {title: title, desp: body} requests.post(url, datadata, timeout10)推送消息要克制不然就是骚扰。我设置了三个推送级别预警活跃度下降 50%、告警持续 30 分钟低活跃、严重检测到疑似浮头。每个级别有冷却时间同一事件 1 小时内不重复推送。我最初没有做冷却结果第一周晚上氧气泵的气泡被模型误判成“浮头”手机一晚上收到了 26 条推送几乎崩溃。加上冷却和夜间免打扰模式之后推送频率从“每小时多条”降到“每周三五条”大多数还是真正有价值的预警。7. 实际运行中的翻车现场与避坑复盘7.1 玻璃反光和夜间补光让模型“近视”的元凶系统刚跑通时白天的检测率可以达到 95% 以上但一到夜晚就急剧下降。排查发现是玻璃反光把鱼缸外的物体映射进画面模型偶尔会把反光里的绿植重影当成一条鱼。解决办法是给摄像头镜头加了一个偏振镜转动镜片到某个角度反光被大幅削减。同时调整补光灯的位置让它从鱼缸前侧 45 度角照射而不是正对着玻璃垂直照射。这两个改动之后夜间的误检率下降了约 70%。偏振镜这东西我以前觉得只有拍风景才用没想到在室内监控场景也这么管用。如果你没有偏振镜还有一个土办法把摄像头贴在玻璃上用一块黑色布料包住摄像头与玻璃之间的缝隙减少环境光从镜头周围射入也能缓解反光问题。7.2 水面波动跟踪器断线的第一个原因鱼缸的水面不是静止的特别是开了氧气泵之后水面会出现大量波纹和气泡。检测框和水面波纹之间的像素变化会让 ByteTrack 误以为有新目标进入从而打断原有轨迹。我的处理方式是调整摄像头安装角度让它从鱼缸正面的偏上方斜向下拍摄尽量避开水面区域。如果一定要拍摄完整的鱼缸正面那就需要在检测后加一个 ROI 过滤把画面最上方 10% 的区域剔除掉。还有一个经验氧气泵的气泡如果正好从检测兴趣区域中间穿过会造成检测框在气泡和目标之间反复跳变。我最后把氧气泵的出气口移到了鱼缸角落远离中间视野这个问题就少了很多。硬件排布影响 AI 效果这点在做视觉项目时很容易被忽略。7.3 数据漂移鱼的颜色居然会变有一个我完全没想到的问题鱼的体色会随环境、情绪、健康状况变化。宝莲灯在发情期颜色格外鲜艳病弱时颜色会变淡如果模型对颜色特征的依赖过强就会出现“同一条鱼今天识别得出、明天识别不出”的怪象。为了降低这种影响我在训练时把 HSV 增强的幅度调得更大了一点让模型更多依赖鱼的形状和轮廓特征而不是单纯依赖颜色值。熬过这个阶段之后模型在颜色变化较大的情况下依然能保持稳定识别。这种问题在工作里叫“数据漂移”其实养殖行业很常见。鱼、观赏植物、甚至宠物毛色都会随季节变化视觉模型如果只在单一季节的数据上训练换季之后精度就会打折。所以我现在打算每隔三个月补采一轮数据让模型跟着自然环境一起“换季”。7.4 长期运行散热、水汽和微生物Jetson 设备放在鱼缸旁边长期运行面临三个实际问题散热、水汽、藻类滋生。设备外壳上建议加一块小的散热片机箱尽量选择侧面有通风格栅的款式但注意不要让水汽直接通过风扇吸入设备内部。我在设备外又套了一个防水透气袋效果不错。摄像头镜头上过一两个月就会积累一层水垢检测效果会逐渐下降所以维护计划里要加上“每两周擦拭一次镜头”这个动作。别小看这个细节很多时候系统误检率突然上升不是代码出了问题而是镜头脏了。7.5 我能给后来者的几个建议第一个建议是“先跑通最小闭环再追求完美”。我一开始就想直接做全功能结果在模型训练阶段卡了快三周项目的正反馈迟迟不来。后来把目标缩小到“只要能实时识别鱼种”一周就完成了之后所有功能都是在这个基础上逐步叠加的。第二个建议是“不要迷信模型指标”。mAP 再高如果训练集和你的鱼缸环境差异很大部署效果依然可能很差。模型的验证应该以自家鱼缸的连续视频为准而不是只看验证集数字。第三个建议是关于复盘的每一条误检都值得保存。我在跑系统时不断把识别错误的帧存成 JPEG定期回看用这些错误样本去扩充训练集模型会越用越准。坚持了三个月误检率从最初的 8% 降到了 2% 左右。最后再分享一个很小的技巧当你准备把摄像头安装到鱼缸上时先拍一张带有标尺的照片。因为摄像头的安装角度和高度会影响像素距离和实际距离的比例标定一次之后活跃度的“像素”指标就可以粗略换算成“厘米”后续做行为判断时会更直观。我后来做“鱼的平均游动速度”和“活动半径”统计时全靠这张照片作为换算基准省了不少麻烦。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →