☰
YOLOv11改进实践:野生动物监测的小目标检测与边缘端部署
2026/9/30 19:58:45 网站建设 项目流程

简介:在野生动物种群监测中,红外触发相机采集的图像常面临目标尺寸小、夜间低光照、连拍冗余等挑战,直接使用官方预训练权重往往难以满足精度需求。目标检测模型YOLOv11作为新一代高效算法,其结构可由用户按场景调整。通过引入P2小目标检测头、注意力模块以及数据侧的去重抽帧与半自动标注,可显著提升小尺度动物的召回率。同时,针对边缘设备如Jetson的TensorRT导出、INT8量化与目标跟踪后处理,能够有效降低空触发误报并支撑长期监测统计。本文围绕数据清洗、模型结构改进、训练调参与部署避坑的完整链路,给出可供工程实践直接参考的参数与脚本,为生态研究者将视觉AI落地到野外监测场景提供了可复用的解决方案。

1. 为什么野生动物监测需要改 YOLOv11,而不是直接拿官方权重

做野生动物种群监测的人,大多绕不开红外触发相机(camera trap)回收回来的那一堆照片。我接触过的项目里,一张 64G 存储卡一个月能带回几千张图,真正有动物的不到三分之一,剩下全是风吹草动、温度变化触发的空镜;有动物的那部分里,雪兔、黄鼬这类目标经常只有三四十个像素,夜间红外光照下整张图灰绿一片,动物一出框就是一团运动模糊。

这种数据直接丢给官方 YOLOv11 预训练权重,效果通常很差。COCO 的 80 个类别里,野生动物基本被归并成 bird、dog、cat 这类粗粒度类,白尾鹿和麋鹿在模型看来没什么区别;更麻烦的是,官方权重是在自然光图上训出来的,红外夜图在特征分布上天然偏移。所谓“YOLOv11 改进模型在野生动物种群监测中的实践”,核心就是围绕三个问题展开:数据侧怎么清洗和标注,结构侧怎么针对小目标和低光照做小目标优化,部署侧怎么在 Jetson 这类边缘设备上把推理结果真正落成可统计的监测数据。这篇笔记按这条线往下拆,每一步都给出能直接抄的参数和踩坑记录。

2. 数据先于模型:红外相机图像的清洗、去重与半自动标注

野生动物监测项目的首要瓶颈不是模型结构,而是标签质量。红外触发相机的拍摄机制决定了数据里有大量“连拍连环画”,同一个动物从进框到出框可能被触发 5 到 10 张,相邻帧差异极小。如果这些图全部进训练集,模型会死记连拍样本,验证集指标虚高,部署到新相机点位时立刻现原形。我一般把数据准备分成三步:去重抽帧、半自动标注、类别与尺度均衡。

2.1 去重与抽帧:从连拍照片里挑出“高信息量”样本

先看一个最小可用的清晰度筛选脚本,用来从一组连拍图里挑出最清楚的一张:

import os import numpy as np from PIL import Image def pick_sharpest(folder): files = sorted([ f for f in os.listdir(folder) if f.lower().endswith(('.jpg', '.jpeg', '.png')) ]) candidates = [] for name in files: img = Image.open(os.path.join(folder, name)).convert('L') arr = np.asarray(img, dtype=np.float32) # 用 Laplacian 梯度的平方均值近似清晰度 gy, gx = np.gradient(arr) sharpness = (gx**2 + gy**2).mean() candidates.append((sharpness, name)) candidates.sort(reverse=True) return candidates[0][1]

逻辑说明:把图像转成灰度,用 numpy 的np.gradient算出横向和纵向梯度,梯度均值越大说明边缘越锐利,运动模糊或失焦图的梯度会明显偏低。这个方法不严谨,但做数据清洗够用。

参数说明:红外夜间图噪声大,挑图时不要只看清晰度,还要结合拍摄时间。常见做法是先按文件名里的时间戳把同一触发事件归组,比如“同一相机同一分钟内的照片”视为一组,组内用上面的函数挑一张。如果连拍间隔只有几百毫秒,按分钟归组就够了;万一相机时区设置有偏差,就把阈值放宽到两分钟。这一步能直接砍掉 60% 以上的冗余图。

2.2 半自动标注:用 YOLOv11 预训练权重打底稿,人工只做修正

数据清洗完,接下来是标注。几百张图人工框不现实,更合理的路径是让 YOLOv11 先当一次标注工,把所有红外图跑一遍预训练推理,把预测框当作“底稿”,再人工修正类别和边界。命令如下:

yolo predict model=yolo11s.pt source=./raw_camera_images/ \ imgsz=640 conf=0.25 \ save_txt=True save_conf=True \ project=./auto_labels

注意这里的save_txt=True和save_conf=True必须同时写:前者让每张图生成同名 txt 标签文件,内容是 YOLO 格式的class x_center y_center width height;后者会把置信度追加到每一行的第 6 位,后续可以用这个置信度过滤低质量底稿。

跑完之后,auto_labels/labels/下会有一批非空 txt,直接作为初版标签。然后用 LabelImg 或 CVAT 打开图,不要从零画框,只做三件事:删掉明显的误检、把类别修正到自定义物种名、给漏检的小目标补框。这个流程里最耗时的不是画框,而是“判断模型给的框到底靠不靠谱”,所以我习惯把 conf 调到 0.25,保留更多低置信度候选,宁可人工删也不要人工补。

2.3 类别不均衡与小目标增强:让稀有物种进得去 loss

野生动物数据天然不均衡:一群白尾鹿的照片可能有几千张,赤狐可能只有十几张。YOLOv11 的默认增强不解决这个问题,需要手动做类别均衡。下面这个脚本统计每个类别的样本量,并算出过采样权重:

from collections import Counter label_files = [] with open('dataset/train.txt') as f: label_files = f.read().splitlines() cls_count = Counter() for path in label_files: with open(path) as lf: for line in lf: cls_count[int(line.split()[0])] += 1 max_count = max(cls_count.values()) weights = {c: max_count / n for c, n in cls_count.items()} print(weights)

逻辑说明:统计后按“最大类样本数除以当前类样本数”得到权重,比如鹿有 1000 张、赤狐有 20 张,赤狐的权重就是 50。实际训练时可以采用两类做法:一是把稀有类的图片路径重复复制进数据集,二是配合 PyTorch 的 WeightedRandomSampler 做加权采样。我一般选前者,简单直接,也不容易把训练循环搞复杂。

小目标增强方面,我常用的 albumentations 配置如下:

import albumentations as A train_aug = A.Compose([ A.RandomBrightnessContrast(brightness_limit=0.15, contrast_limit=0.15, p=0.3), A.RandomSizedCrop(min_max_height=(320, 512), height=640, width=640, p=0.4), A.HueSaturationValue(hue_shift_limit=5, sat_shift_limit=15, val_shift_limit=10, p=0.2), A.Mosaic(p=0.3), ], bbox_params=A.BboxParams(format='yolo', label_fields=['class_labels']))

参数说明:红外夜间图本身亮度变化剧烈,brightness_limit不要超过 0.2,不然模型会把亮度当成类别特征;Mosaic把四张图拼成一张,能显著提高单图小目标密度,但动物经常被裁断,p=0.3是兼顾增强和标签噪声的经验值。色相增强要克制,hue_shift_limit=5是因为红外图整体偏绿,大幅色相偏移会造出训练时根本不存在的颜色分布。

3. 模型结构改进:小目标头、注意力与轻量化骨干怎么选

数据准备好以后,才轮到 YOLOv11 改进。先说结论:对野生动物监测来说,收益最大的结构改动是加 P2 小目标检测头,其次是在 backbone 靠近输出侧加注意力,最后才是换轻量化骨干。顺序反了容易白忙一场。

3.1 YOLOv11 结构改动前先看哪几个层

YOLOv11 的 yaml 网络结构相对 YOLOv8 主要变化是 C3k2 模块替代了一部分 C3,SPPF 后面多了 C2PSA,检测头仍然沿用解耦头。改动前先确认检测头的 stride,也就是每个检测分支的下采样倍数:

from ultralytics import YOLO model = YOLO('yolo11s.pt') detect = model.model.model[-1] print('检测层 stride:', detect.stride.tolist())

正常情况下应该输出[8, 16, 32],对应输入 640 时三个检测分支分别在 80×80、40×40、20×20 特征图上做预测。对野生动物场景来说,动物经常只占画面的 1/16 甚至更小,20×20 和 40×40 这两个粗粒度分支几乎帮不上忙,所以要把检测尺度往下压到 4 倍下采样,也就是 P2 层。改完以后重复运行上面的代码,stride 变成[4, 8, 16],才算生效。

3.2 加 P2 小目标检测头:改 yaml 和自检方法

P2 层对应的是输入图下采样 4 倍后的特征图,空间分辨率高,保留了小目标的边缘细节,代价是计算量和显存增加 20% 到 30%。野外监控对小目标召回率的优先级远高于省那点显存,所以这笔账值得算。

下面的 yaml 片段是基于本地 YOLOv11 源码包里的yolo11.yaml改的,核心思路是把原来 Detech 的三个输入从 P3/P4/P5 换成 P2/P3/P4:

head: - [-1, 1, nn.Upsample, [None, 2, "nearest"]] - [[-1, 6], 1, Concat, [1]] # 来自 backbone P4 - [-1, 1, C3k2, [512, False, 0.25]] - [-1, 1, nn.Upsample, [None, 2, "nearest"]] - [[-1, 2], 1, Concat, [1]] # 来自 backbone 第 2 层,即 P2 - [-1, 1, C3k2, [128, False, 0.25]] - [-1, 1, Conv, [256, 3, 2]] - [[-1, 4], 1, Concat, [1]] # 下采样回 P3 - [-1, 1, C3k2, [256, False, 0.25]] - [-1, 1, Conv, [512, 3, 2]] - [[-1, 6], 1, Concat, [1]] # 下采样回 P4 - [-1, 1, C3k2, [512, False, 0.25]] - [15, 18, 21, 1, Detect, [nc]]

逻辑说明:yaml 里每一行的[-1, x]表示“取上一层输出”和“取 backbone 第 x 层输出”做 Concat。P2 分支的 Concat 索引改成2,是把 backbone 第 2 层的细粒度特征引进来;后面再接两个Conv + Concat的下采样支路,是为了让 P3、P4 分支仍然有来自主干的多尺度信息。

注意:不同 YOLOv11 小版本的行号可能有 1 到 2 位偏移,因此上面的片段不能无脑覆盖。改完以后务必用 3.1 里的 stride 自检代码确认输出是[4, 8, 16]。如果还是[8, 16, 32],多半是 Concat 索引没指向真正的 P2 层。

3.3 注意力与轻量化骨干的取舍

加了 P2 头以后,模型对小目标的召回率会有可见提升,但夜间低光照下误检率也会起来。这时候可以考虑在 backbone 靠近输出的 C3k2 后面加注意力模块,常见做法是 EMA、CA 或 SE 三选一。EMA 对通道注意力的建模更细,小目标受益明显;CA 适合类别间容易混淆的场景;SE 最轻,几乎不涨参数量。

注意力的插入方式是在 backbone 的 C3k2 后面加一行:

- [-1, 1, EMA, [128]]

参数说明:[128]里的数字要与上一层输出通道一致。无论 EMA、CA 还是 SE,都需要把对应类注册进ultralytics.nn.modules,否则训练会直接报模块找不到。如果源码包里已经有现成的注意力模块,优先用包内实现,别自己重写,重写后调试成本远大于收益。

轻量化骨干的优先级放在最后。Jetson Nano 这类设备上,如果跑 YOLOv11s 已经只剩 4 到 6 FPS,可以考虑换成 MobileNetV4 或 GhostNet 骨干,能把前向时间压到原来的 60% 左右,但 mAP 会掉 0.5 到 2 个点。我的建议是:先保住 P2 头和注意力,骨干只在实测帧率不达标时再换,不要一开始就把模型结构改成“什么都想要”的缝合怪。

4. 训练与调参:环境、超参数、早停与模型挑选

结构改完,训练环节决定上限。这里说的训练不是随便跑几百个 epoch 就收工,而是要把数据划分、超参数、收敛判断这些环节卡死,避免出现训练时指标好看、部署后当场翻车的情况。

4.1 环境配置与单卡注意事项

YOLOv11 的环境配置比早期 YOLOv5 省心很多,核心就三步:

conda create -n yolo11 python=3.10 -y conda activate yolo11 pip install ultralytics

逻辑说明:Python 3.10 和当前主流 PyTorch 版本的兼容性最稳;pip install ultralytics会带起 PyTorch、torchvision 和推理依赖。有 NVIDIA 显卡时确认 CUDA 版本和 PyTorch 匹配,用python -c "import torch; print(torch.cuda.is_available())"验证。项目数据量不大时,YOLOv11n 用 CPU 也能训练,只是要把imgsz降到 416 以下,不然一个 epoch 跑到天荒地老。

显存方面,我一般用nvidia-smi看剩余显存,然后根据图像尺寸估算 batch。640×640 输入、YOLOv11s 加 P2 头,batch 16 大约需要 12GB 显存;显存紧就 batch 8 加梯度累积,不要把输入尺寸一起降了,小目标场景吃分辨率。

4.2 输入尺寸、自适应锚框与关键超参数

训练命令的完整形式:

yolo detect train model=yolo11-wildlife-p2.yaml data=wildlife.yaml \ epochs=150 imgsz=640 batch=16 lr0=0.01 \ optimizer=auto close_mosaic=10 patience=15 \ project=runs/wildlife name=p2-ema

参数说明:

  • imgsz=640是速度和召回率的中间值。动物普遍小于画面 1/16 时,可以试imgsz=960,P2 头能发挥更大作用,但显存占用会翻倍。
  • close_mosaic=10表示最后 10 个 epoch 关闭 Mosaic 增强。Mosaic 会生成拼接图,和真实部署时的画面分布不一致,最后几个 epoch 关掉,让模型适应未拼接图像。
  • patience=15表示验证集指标连续 15 个 epoch 不提升就早停。野生动物数据集小,训练到 80 个 epoch 附近容易过拟合,早停能省下大量时间。
  • optimizer=auto让 Ultralytics 按模型尺寸自动选优化器,YOLOv11s 一般落到 SGD 或 AdamW,不需要手工折腾。

自适应锚框这项,Ultralytics 在训练启动时会根据你的标签自动重新计算锚框,不需要手动设置。如果你的数据里目标普遍偏小,训练日志里会看到锚框尺寸整体变小,这是正常现象,不要因为锚框数值陌生就改成 COCO 默认值。

4.3 判断收敛:从 loss 曲线到混淆矩阵

训练结束后不要只看最后一个 epoch 的指标,进到输出目录看曲线:

ls runs/wildlife/p2-ema/

里面会有results.png和confusion_matrix.png。results.png包含 box_loss、cls_loss、dfl_loss 三条训练曲线和验证曲线;看收敛时我习惯只盯验证集 box_loss,它持续下降说明还在学,进入平台期后看patience停工。

混淆矩阵更关键。野生动物最常见的错误不是把树当动物,而是把近缘物种互相认错,比如白尾鹿和骡鹿、赤狐和郊狼。混淆矩阵里能看到鹿那行有一块明显的“错误落点”,说明数据里缺少区分这两类的边界样本,解决方法是补充对应姿态和光照下的照片,而不是加大模型。模型文件选择上,我一般取best.pt,也就是验证集 mAP50 和 mAP50-95 综合最优的权重;如果最后 20 个 epoch 的权重比 mid-training 的 best 差得远,说明增强策略过强,回看close_mosaic是否生效。

5. 部署与避坑:Jetson 上的 TensorRT 导出、INT8 与五条翻车记录

模型训练完,真正的工程问题从部署才开始。边缘端部署 YOLOv11 最常见的路径是导出 TensorRT engine,再配合目标跟踪做连续帧后处理。这一步踩坑极多,我把最容易翻车的五条按“现象、原因、解决”列出来。

5.1 部署环境与导出命令

Jetson 设备上先确认 JetPack 版本再装依赖:

sudo apt update pip install ultralytics python -c "import torch; print(torch.__version__, torch.cuda.is_available())"

逻辑说明:Jetson 上的 PyTorch 由 JetPack 的 pip 源提供,直接pip install torch很可能装成 x86 版,导致torch.cuda.is_available()返回 False。装完先跑这行验证,确认 True 再继续。

TensorRT engine 的导出,我习惯在 x86 主机上先生成,再拷贝到 Jetson 上推理,省得在 Jetson 上反复编译:

yolo export model=runs/wildlife/p2-ema/weights/best.pt \ format=engine imgsz=640 half=True workspace=4

参数说明:format=engine让 Ultralytics 调 TensorRT 导出;half=True启用 FP16;workspace=4限制 TensorRT 构建时最多用 4GB 显存,Jetson 显存不足时往下调到 2。如果要做 INT8 量化:

yolo export model=runs/wildlife/p2-ema/weights/best.pt \ format=engine int8=True calib_data=./real_infrared_imgs/

这里calib_data目录里放的是真实红外触发相机照片,而不是通用自然光图。校准数据决定了量化时激活值的动态范围,红外图和自然光的分布差很远,用 COCO 图做校准,导出后小目标检测能力会明显退化。

5.2 避坑一:INT8 量化后小目标全部消失

现象:FP16 engine 部署后 mAP50 大约下降不到 1 个点,INT8 engine 直接掉 4 到 7 个点,P2 头侦测到的小目标几乎全灭。

原因:小目标在特征图上的激活值幅度本来就很低,INT8 量化把这些低幅值激活压到相邻的量化 bin 里,等效于把小目标的语义信息抹掉了。P2 分支对这种量化误差尤其敏感。

解决:先确认calib_data里有包含小目标的中红外图样本,至少覆盖模型要识别的所有类别;校准图片数量取 100 到 300 张,不要拿几百张全是空镜的图凑数。如果校准换完数据仍然掉点严重,退回 FP16,不要为了省内存硬上 INT8。Jetson Nano 上 FP16 的加速比已经够用。

5.3 避坑二:空触发图造成误报虚高

现象:模型跑通后,统计某个月检测到动物的次数,发现结果比人工复核高出一倍,点开报警截图全是枯枝、树影和晃动的草。

原因:红外触发相机对温度变化敏感,画面里根本没有动物也会触发;模型从红外图里学到的是“一团具有边缘和温度的物体”,树枝的形态和动物肢体在模糊状态下很难区分。

解决:部署时加目标跟踪和最小连续帧数,只对连续两帧以上出现的同一目标计数。命令如下:

yolo track model=best.engine source=./video/ persist=True \ conf=0.25 iou=0.5 \ save_txt=True save_conf=True

参数说明:persist=True让跟踪器跨帧保持目标 ID;conf=0.25对夜间场景可以再调到 0.3。后处理里按目标 ID 统计“有效检测次数”,如果一个 ID 只在单帧出现,直接丢弃。再加一道面积过滤,目标像素小于 12×12 的框不参与计数。这两条能挡掉大部分空触发误报。

5.4 避坑三:连拍数据泄露,指标好看出不了报告

现象:训练时验证集 mAP50 到了 0.92,部署到新的相机点位后掉到 0.6,怀疑模型根本没学会泛化。

原因:数据预处理时按“单张图片”随机划分 train/val,同一个触发事件的连拍帧被同时分到了两边。验证集里出现了和训练集几乎一样的图,指标虚高。这也是前面抽帧环节没有做彻底留下的后遗症。

解决:按“事件”划分数据,而不是按图划分。把同一相机、同一分钟内的照片归为一组,整组放进 train 或整组放进 val。脚本示意:

import glob import collections groups = collections.defaultdict(list) for path in glob.glob('raw/*.jpg'): # 文件名约定: camera01_20240301_023110_001.jpg parts = os.path.basename(path).replace('.jpg', '').split('_') key = (parts[0], parts[1], parts[2][:4]) # 相机 + 日期 + 小时 groups[key].append(path)

逻辑说明:key用相机 ID、日期和小时粗分,能把连拍帧归到一个事件桶里;粒度更细就用“日期 + 分钟”组合。划分时按group维度做 80/20 切分,保证同一个事件不会跨 train 和 val。这个坑在野外监控里最常见,也最影响报告可信度。

5.5 避坑四:训练中途换设备导致结果对不上

现象:训练到 50 个 epoch 时从 RTX 3090 迁到 A100,后续 loss 曲线开始震荡,验证指标比原来还差。

原因:换卡后 batch size 常常跟着调大,学习率没有同步缩放,优化器状态里的 momentum 也被打乱。PyTorch 恢复 checkpoint 时并不纠正学习率,原计划里的 lr schedule 从新起点重新走,于是模型在已经接近收敛的位置被“重新加热”了。

解决:中途换设备时固定 batch size 不变,或者把学习率降到原来的四分之一再恢复训练。如果新卡显存大很多,确实想加大 batch,就把lr0同步调大,但不要超过原值的两倍,然后观察 5 个 epoch 的 loss 走向。稳妥起见,我通常直接拿best.pt重新从头训,而不是续跑last.pt,浪费一点时间换确定性。

5.6 避坑五:保存推理结果的路径与格式

现象:批量推理跑完,发现只生成了画框图,找不到检测结果的 txt 文件,统计脚本直接报错。

原因:yolo predict默认只保存可视化图片,不会保存标签文件;save_txt和save_conf必须显式打开,很多人漏了这一个参数。

解决:推理时把参数写全:

yolo predict model=best.engine source=camera_images/ \ conf=0.25 save_txt=True save_conf=True \ project=runs/detect name=save_result

输出 txt 在runs/detect/save_result/labels/下,每行格式是class x_center y_center width height conf。要做种群统计,txt 不方便筛选,我在保存后习惯转一份 CSV:

import csv import glob rows = [] for txt in glob.glob('runs/detect/save_result/labels/*.txt'): img_name = txt.split('/')[-1].replace('.txt', '.jpg') with open(txt) as f: for line in f: cls, xc, yc, w, h, conf = line.split() rows.append([img_name, cls, xc, yc, w, h, conf]) with open('detections.csv', 'w', newline='') as f: writer = csv.writer(f) writer.writerow(['image', 'class', 'xc', 'yc', 'w', 'h', 'conf']) writer.writerows(rows)

逻辑说明:CSV 的好处是后续用 Pandas 或 Excel 做透视表都很方便,比如按物种、按相机点位、按日期统计检出数,比对着 txt 文件一个个翻高效得多。建议把这套转换脚本直接放在部署目录里,每次推理完自动跑一遍。

6. 验证与进阶:用置信度分桶和时间外验证调出可用模型

模型部署上去不是终点,还得回答“这个模型的结果能不能用于种群监测报告”。我常用的验证手段是置信度分桶,先故意把置信度阈值调得很低,把所有可能目标都捞出来,再按置信度分桶人工复核,找到漏检和误报的平衡点。命令先跑一遍低阈值推理:

yolo predict model=best.engine source=independent_month/ \ conf=0.05 save_txt=True save_conf=True

conf 取 0.05 是为了不漏检,代价是误报会非常多,没关系,后处理来过滤。然后用脚本按置信度分桶统计:

import csv import random import collections buckets = collections.defaultdict(list) for row in rows: # rows 来自上一节的 detections.csv conf = float(row[-1]) buckets[round(conf, 1)].append(row) for conf, items in sorted(buckets.items()): sample = random.sample(items, min(20, len(items))) print(conf, len(items), len(sample))

逻辑说明:0.1 到 0.9 每个置信度桶里抽 20 张左右人工核对,就能画出一条“置信度对精确率”的曲线。比如 0.3 以下基本全是误报,0.4 以上全部正确,那部署阈值就可以定在 0.35 到 0.4 之间。夜间空镜多的月份,阈值提到 0.4 能砍掉大量枯枝误检,代价是蹲伏状态的豹猫可能漏掉;到底取多少,取决于调查目的是“尽量不遗漏”还是“尽量少报错”。

时间外验证是另一道保险:模型训练时用的数据来自某几个点位,部署后至少保留一个从未进过训练集的新点位做月度抽检。每季度抽 200 张图人工复核,重新算一次 mAP,就能看出模型是否随着季节植被变化发生漂移。我见过不少项目模型在秋季落叶后误报率暴涨,就是因为训练数据里没有对应季节的图像。

最后说一个我自己的习惯:每次训练和部署,都把训练命令、阈值、模型文件路径、校准图片目录写进模型目录下的一个config.txt。三个月后回来看,你会感谢当时多写的这几行字——否则面对一堆 best.engine 和 runs 目录,谁都不记得当时为什么要用 0.4 的阈值。写清楚配置,后悔药就有了。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询