简介:面向智慧牧场、畜牧业智能化与目标检测算法落地场景,这份牛羊检测数据集包含3538张真实场景图片,标注目标涵盖“牛”“羊”两类,适合课程作业、算法比赛、毕业设计及实际农场系统中的牛群羊群识别与计数任务。压缩包内提供jpg原图以及voc(xml)、yolo(txt)、json三种主流标签格式,其中txt标签3539个、xml标签3538个,整体约706.2MB,解压后可根据训练框架选用对应格式,几乎无需格式转换。数据集源自博主实际项目沉淀,图像分布均匀、背景丰富、角度多样,目标尺寸与遮挡情况贴近真实环境,标注精准且经过算法验证,在常见检测模型上拟合良好,能有效支撑模型训练与评估。目前已有471人学习下载,无论用于入门练手还是推动智慧农业项目落地,都是可直接上手的高质量数据集。
1. 智慧牧场牛羊检测数据集:三份标签把训练准备期从三天压到三小时
智慧牧场牛羊检测数据集,核心词在“3538张”和“voc(xml)+yolo(txt)+json三种格式标签”。对天天和标注打交道的工程师来说,这等于拿到了一个标准的检测数据集包:图片数量能支撑一轮像样的预训练微调,三种标签覆盖了从传统检测流程到 YOLO 系训练再到 JSON 交换的全部口味。但压缩包解压只是开始,真正值钱的是接下来怎么把这三份标签对齐、划分、转格式、跑通第一轮训练。这篇笔记就沿着一条可复现的路径走:先解析三种格式,再做转换和训练,最后讲踩坑和验证,适合刚拿到数据集就要出指标、或者准备把模型部署到牧场监控设备上的从业者。
2. 读懂三份标签:VOC XML、YOLO TXT、JSON 的坐标系与解析脚本
2.1 同一只牛羊,在三种标注里的坐标系表述完全不同
一张 1280×720 的牧场图像里有一只牛,VOC 的 XML 会用 xmin、ymin、xmax、ymax 给出绝对像素框;YOLO 的 TXT 给出的是类别 id 和 center_x、center_y、width、height,全部除以图像宽高做了归一化;JSON 则可能是 COCO 那种 image_id 加 bbox 加 area 的组装方式,也可能是一个更自由的数组结构。它们描述同一个目标,但坐标系和字段名完全不同,这就是为什么很多人解压后第一反应是“直接用”,第二反应是被某个格式卡住。
我在处理这类多格式数据集时,先把三种格式的读取脚本各写一遍,然后随机抽 50 张图做交叉验证。核心思路是:不管最终用 YOLO 还是 Detectron2 训练,都要先把三份标签统一到同一个数学空间中,也就是像素坐标。YOLO 归一化坐标乘以真实宽高、VOC 绝对坐标直接用、JSON 里的 bbox 按它的定义换算,三组框在图上叠加后用 OpenCV 画出来人工核对,这一步能挡掉后面 80% 的诡异精度问题。
2.2 解析 VOC XML:重点不在读取,在于 bndbox 的缺失保护
import xml.etree.ElementTree as ET from collections import Counter def parse_voc_xml(xml_path): tree = ET.parse(xml_path) root = tree.getroot() filename = root.findtext("filename") width = int(root.findtext("size/width")) height = int(root.findtext("size/height")) objs = [] for obj in root.findall("object"): name = obj.findtext("name") if name is None: continue bndbox = obj.find("bndbox") if bndbox is None: print(f"[warn] {xml_path} 里有 object 但没有 bndbox") continue xmin = float(bndbox.findtext("xmin")) ymin = float(bndbox.findtext("ymin")) xmax = float(bndbox.findtext("xmax")) ymax = float(bndbox.findtext("ymax")) objs.append({"name": name, "bbox": [xmin, ymin, xmax, ymax]}) return {"filename": filename, "width": width, "height": height, "objs": objs} # 先抽 100 张统计类别名,确认名字到底是 cow 还是 cattle counts = Counter() xml_files = os.listdir("voc_annotations")[:100] for xml_name in xml_files: d = parse_voc_xml(f"voc_annotations/{xml_name}") counts.update([o["name"] for o in d["objs"]]) print(counts)解析逻辑不复杂,真正的保护点在于findtext的 None 判断。标注环节经常埋雷:某个<object>缺了<bndbox>,某个 name 写成了Cow而不是全小写cow,某个坐标写成了字符串带逗号。用 ET 的 findtext 比 find 再取 text 更稳,因为前者天然返回 None 而不是抛异常。代码最后的 Counter 是推荐步骤,先统计类别名再决定要不要在转换脚本里做映射,避免出现cow、Cattle、牛三个名字并存的情况。抽样 100 张而不是全部跑,是为了先快速确认标签口径,全量跑会刷屏,反而看不清。
2.3 解析 YOLO TXT:归一化坐标必须拿真实宽高当分母
from PIL import Image def parse_yolo_txt(txt_path, img_path): img = Image.open(img_path) img_w, img_h = img.size objs = [] with open(txt_path, "r", encoding="utf-8") as f: for line in f: parts = line.strip().split() if len(parts) < 5: continue cls_id, cx, cy, bw, bh = [float(p) for p in parts[:5]] if cx <= 0 or cy <= 0 or bw <= 0 or bh <= 0: print(f"[warn] {txt_path} 有非法框: {line}") continue if cx > 1.001 or bw > 1.001: print(f"[warn] {txt_path} 疑似未归一化坐标: {line}") continue xmin = (cx - bw / 2) * img_w ymin = (cy - bh / 2) * img_h xmax = (cx + bw / 2) * img_w ymax = (cy + bh / 2) * img_h objs.append({"cls_id": int(cls_id), "bbox": [xmin, ymin, xmax, ymax]}) return objs这段代码里最容易忽略的是把图片路径传进来,而不是手写死一组宽高。数据集里如果混了 1920×1080 和 1280×720 两种分辨率,归一化坐标本身没问题,但还原成绝对坐标时用错一张图的分母,框就会整体右移或下移。所以解析函数必须用 PIL 读 real size。值域检查cx > 1.001是第二道防线,有的标注工具在 json 转换或导出时忘了归一化,会直接写绝对像素,这类行越早拦下来越省时间。
2.4 解析 JSON:先看结构再写代码,COCO 段式不是唯一形态
import json def probe_json(path, max_depth=3): with open(path, "r", encoding="utf-8") as f: data = json.load(f) def walk(obj, depth): if depth > max_depth: return if isinstance(obj, dict): for k, v in obj.items(): hint = type(v).__name__ if isinstance(v, list) and len(v) > 0: hint += f"<{len(v)}> of {type(v[0]).__name__}" print(" " * depth + f"{k}: {hint}") walk(v, depth + 1) elif isinstance(obj, list) and len(obj) > 0: print(" " * depth + f"[list] len={len(obj)}") walk(obj[0], depth + 1) walk(data, 0) probe_json("annotations.json")JSON 是所有格式里最自由也最容易写坏的。它可能是标准 COCO 的images、annotations、categories三段式,也可能只是把每张图的框直接塞进一个顶层数组。如果拿 COCO API 直接加载一个非 COCO 结构,报错会非常抽象。上面这个探针脚本就是训练前的快速体检,打了键名和类型,三秒内判断能不能用 pycocotools。确认是 COCO 结构后再解析 categories 拿类别 id 对照表,把 id 和 VOC 的 name 对齐,这是后面做 json 转换和跨框架评测的基础。
2.5 三种格式的类别对齐:谁来决定类别 id
这个坑不显眼但影响致命。YOLO TXT 里只有整数 id,类别名被压缩掉了;VOC 的 XML 里只有字符串名字;JSON 的 categories 又可能和两者都不一样。三份标签如果各自定义了一套 id,训练时类别就错位了。我在拿到数据集后,会先做一个 category mapping:
voc_names = ["cow", "sheep"] # 从 XML 里统计出来 yolo_id_to_name = {0: "cow", 1: "sheep"} # 从训练类别文件抄出 json_categories = {1: "cow", 2: "sheep"} # 从 JSON categories 段解析 for name in voc_names: if name not in yolo_id_to_name.values(): print(f"[err] YOLO 里缺少类别 {name}") if name not in json_categories.values(): print(f"[err] JSON 里缺少类别 {name}")这段脚本本身不是算法,但它是所有转换的前提。只要有一个格式的类别名不一致,后面转换出的训练集就是错的。判断哪一份是对的,我一般以 VOC XML 为准,因为名字可读性最强,错了容易肉眼发现;以 JSON 为交换基准,因为 COCO 的 categories 能承载更多信息。两边核对完,再把 YOLO 的 id 映射表同步过来,保证整个项目里只有一个权威类别表。这套对齐动作做完,三种格式才能真的互相翻译。
3. 用 3538 张图片跑通第一轮训练:VOC 转 YOLO 与最小命令
3.1 目录结构:哪种摆放方式能同时喂给 YOLOv8 和 Detectron2
smart-pasture/ ├── images/ │ ├── train/ │ └── val/ ├── labels/ │ ├── train/ │ └── val/ ├── voc_annotations/ │ ├── train/ │ └── val/ ├── json_annotations/ │ ├── train/ │ └── val/ ├── data.yaml └── category_map.json训练只吃labels下的 TXT,但不要因为只吃 TXT 就把 XML 和 JSON 删掉。后续做模型对比评测时,很多检测框架要 VOC 格式的 XML 作为评估输入;交付给上游系统时,对方往往只收 JSON。保留原始格式是为了不再转一圈,也为了给category_map.json留一份权威的类别映射记录。目录结构上,我习惯让 images 和 labels 严格对齐,train 里出现的每张图都必须有对应的同名 txt,否则训练脚本会静默跳过。
3.2 用脚本批量把 VOC XML 转成 YOLO TXT
import os from PIL import Image import xml.etree.ElementTree as ET CLASS_MAP = {"cow": 0, "sheep": 1} def voc_xml_to_yolo_txt(xml_path, txt_path, img_w, img_h): tree = ET.parse(xml_path) root = tree.getroot() lines = [] for obj in root.findall("object"): name = obj.findtext("name") if name not in CLASS_MAP: print(f"[skip] {xml_path} 里不认识的类别 {name}") continue bndbox = obj.find("bndbox") xmin = float(bndbox.findtext("xmin")) ymin = float(bndbox.findtext("ymin")) xmax = float(bndbox.findtext("xmax")) ymax = float(bndbox.findtext("ymax")) if xmin >= xmax or ymin >= ymax: print(f"[warn] {xml_path} 存在宽高为 0 的框,已跳过") continue cx = (xmin + xmax) / 2 / img_w cy = (ymin + ymax) / 2 / img_h w = (xmax - xmin) / img_w h = (ymax - ymin) / img_h lines.append(f"{CLASS_MAP[name]} {cx:.6f} {cy:.6f} {w:.6f} {h:.6f}") with open(txt_path, "w", encoding="utf-8") as f: f.write("\n".join(lines)) xml_dir = "voc_annotations/train" txt_dir = "labels/train" os.makedirs(txt_dir, exist_ok=True) for xml_name in os.listdir(xml_dir): if not xml_name.endswith(".xml"): continue base = xml_name[:-4] img = Image.open(f"images/train/{base}.jpg") w, h = img.size voc_xml_to_yolo_txt( os.path.join(xml_dir, xml_name), os.path.join(txt_dir, base + ".txt"), w, h, )转换逻辑是:VOC 绝对坐标先算出中心点和宽高,再分别除以图像真实宽高,得到 YOLO 的归一化五元组。两个地方不要自作主张改成固定 640:训练时图像会被缩放到输入尺寸,但标签归一化是对原始图做的,和网络输入无关。CLASS_MAP必须和 data.yaml 的类别顺序一致,否则训练出来的类别语义是错位的。脚本里遇到不认识的名字直接 skip 并打日志,方便回头检查哪些图被漏了。转换完随手数一下生成的文件个数,和 XML 数量对得上再继续。
3.3 划分 train/val:按文件名不留重复
import os import random import hashlib images = [f for f in os.listdir("images") if f.endswith(".jpg")] random.seed(42) random.shuffle(images) val_count = int(len(images) * 0.15) val_set = set(images[:val_count]) train_list = images[val_count:] # 检查是否有完全重复的图,防止同图同时进 train 和 val all_bases = {} for img_name in images: content = open(f"images/{img_name}", "rb").read() md5 = hashlib.md5(content).hexdigest() all_bases.setdefault(md5, []).append(img_name) dups = {k: v for k, v in all_bases.items() if len(v) > 1} print(f"重复图片组数: {len(dups)}") for md5, names in list(dups.items())[:5]: print(names)这里有个血泪经验:同一个场景连拍的图,文件大小几乎一样但内容不同,肉眼难分,直接吃进训练集和验证集就会造成数据泄漏。所以我会加一层 md5 查重,把完全一样的图识别出来,确保同一份图不会既在 train 又在 val。划分比例上,3538 张按 85:15 差不多够用,如果要更严谨,可以把 val 再拆一半当 test,最后跑推理时验证泛化。文件名按base去重这个步骤也不能省,防止同一张图带了_1.jpg和_2.jpg两个副本。
3.4 写 data.yaml 并跑通 yolov8n 最小训练
path: smart-pasture train: images/train val: images/val names: 0: cow 1: sheepcd smart-pasture # 首次运行需要准备预训练权重,yolov8n.pt 可以提前下载放到项目目录 yolo detect train \ data=data.yaml \ model=yolov8n.pt \ epochs=50 \ imgsz=640 \ batch=16 \ project=runs/pasture_cow_sheep \ name=exp1这一条命令能跑通,前提是 images 和 labels 目录严格按 YOLO 习惯摆放:train 的标签 txt 文件名必须和图片名一致且扩展名不同,一张都不能差。data.yaml 里的 names 顺序必须和 TXT 里的 id 完全对应,差一位整个训练结果就是反的。batch=16在 3538 张规模下属于稳妥值,如果显存只有 6G,降到 8;imgsz=640是按检测速度与精度的折中,远景羊群小目标多的时候可以试 960,但训练时间会涨很多。这轮跑完后看runs/pasture_cow_sheep/exp1里的 results.csv,mAP50 能到多少心里有数了。
4. 数据划分与类不均衡:牧场场景最影响 mAP 的细节
4.1 划分策略:随机划分没问题,但要防同场景连拍图串集
牧场的图经常是从视频里抽帧出来的,同一群羊连续几十帧高度相似。如果随机划分,train 和 val 里可能都出现同一时刻的帧,指标虚高,换个时段换个相机位就崩。解决办法是按文件名前缀分组划分:
import os import random # 假设文件名格式是 cam01_20240115_143200_001.jpg,前三段是相机、日期、分钟 groups = {} for img in os.listdir("images"): parts = img.split("_") group_key = f"{parts[0]}_{parts[1]}_{parts[2]}" groups.setdefault(group_key, []).append(img) group_names = list(groups.keys()) random.seed(7) random.shuffle(group_names) val_groups = set(group_names[: int(len(group_names) * 0.15)]) train_imgs = [] val_imgs = [] for g, imgs in groups.items(): if g in val_groups: val_imgs.extend(imgs) else: train_imgs.extend(imgs) print(f"train {len(train_imgs)} val {len(val_imgs)}")核心思路是把划分的单位从“单张图”升级为“一个场景组”,因为视频抽帧连拍会让验证集被同一场景的相似背景“白嫖”出一个虚高 mAP。命名规则不是cam01_日期_分钟的话,就换成其他分组键,比如按拍摄时段分,或者按区域分。3538 张的数据量下,宁可牺牲一点训练集规模,也要把划分单位变成场景组,这是投入产出比最高的一层防护。
4.2 羊多牛少:mAP 怎么解读才靠谱
训练时 loss 会被多数类主导,羊的帧占比高,牛的 recall 偏低。再加上牛通常离镜头更近、框更大,羊群多在远处、框小且互相遮挡,这两个类别已经不只是数量差异,而是尺度差异。
python - <<'EOF' from ultralytics import YOLO model = YOLO("runs/pasture_cow_sheep/exp1/weights/best.pt") metrics = model.val(data="data.yaml") for i, name in metrics.names.items(): print(f"{name}: mAP50 = {metrics.box.ap50[i]:.3f}") EOF这段用训练完的最佳权重在 val 上重新评估,直接按类别输出 ap50。如果 cow 的 ap50 是 0.6 而 sheep 是 0.9,说明这个模型上线后对牛的漏检风险更高,只看总 mAP 会被羊的表现掩盖。针对类别不均衡,第一优先不是改损失函数,而是先确认数据增强和尺度:牛框大但不代表好检测,背对镜头的牛很容易只框住一半。可以在训练命令里加class_weights参数给牛加权,或者对牛类做更强的随机裁剪,我一般先从前者试,成本最低。
4.3 图像预处理:EXIF 旋转与灰度图先统一
手机和无人机拍的 JPG 经常带 Orientation 标签,OpenCV 读出来是横的,但标签坐标是按竖图标的,直接训练等于所有框都旋转了 90 度。灰度图和彩色图混存也会影响输入通道的一致性。
from PIL import Image, ImageOps import os def normalize_image(src, dst): img = Image.open(src) img = ImageOps.exif_transpose(img) # 按 EXIF 方向扶正 if img.mode != "RGB": img = img.convert("RGB") # 灰度图统一转 RGB img.save(dst, quality=95) os.makedirs("images", exist_ok=True) for img_name in os.listdir("images_raw"): normalize_image(f"images_raw/{img_name}", f"images/{img_name}")这一步我一般放在最前面做,因为如果 EXIF 旋转没处理,后面的标签转换、可视化、训练全部建立在错误方向上。PIL 的exif_transpose会读取 JPEG 的 Orientation 字段把图转正,转完后标签坐标依然按原图标注的像素值,但图本身方向变了,所以必须用转正后的图重新算标签。灰度图转 RGB 不转也能训,但会增加很多莫名其妙的波动,统一转出去最省心。
5. 避坑:用这张数据集时常见的五个翻车现场
5.1 现象:训练时 loss 正常掉,但验证集 mAP 一直趴在地上
原因是划分泄漏的反面:train 和 val 里的图片虽然是不同的文件,但很多是同一场景连续抽帧,视觉上几乎一样。训练集学到的背景特征在验证集里也大量存在,可一旦换个时段、换个相机位,精度立刻崩。解决方法是先做 md5 查重,再按文件名分组划分,保证同一个拍摄片段整体进 train 或整体进 val。3538 张的数量下,先查重再划分是必须的,不要跳过。
5.2 现象:XML 和 TXT 框对不上,图上的牛位移了半个身位
原因是部分图像被预处理过尺寸,但只有一份标签跟着做了缩放,另一份还停留在原图坐标。特别是 JSON 里如果混了两种标注口径,转出来的框就会整体偏移。解决方法是建立一个“以像素绝对坐标为准”的校验管线:把 XML 还原的框、TXT 还原的框、JSON 还原的框分别画在同一张图上,逐一目检错位图。脚本里对位移超过 5 像素的样本自动报警,再回原图确认哪一份标签才是基准。
5.3 现象:一张图上 30 只羊,训练时 batch 直接 OOM
原因不一定是显存不够,而是默认的 mosaic 增强会把 4 张小图拼成一张,等于一张训练图上同时出现上百个目标框,损失函数计算量和显存占用暴涨。在羊群密集场景里,这是最常见的翻车点。解决方法是把mosaic关掉或者调低batch,在训练命令里加mosaic=0.0先跑通,再用小步长从 0.5 开始试。3538 张的数据量下,mosaic 带来的增益并没有网上说的那么夸张,密集小目标场景宁可先保 batch。
5.4 现象:JSON 里出现空数组,数据加载器当场抛错
原因是对应的图片可能没有目标,标注工具生成了空 list;也可能是 json 转换过程中把某些行过滤掉了。最隐蔽的是 JSON 的 annotations 数组里有一条记录,其 image_id 指向的图片不在 images 里,跑数据检查时经常被忽略。解决方法是遍历 JSON 所有 image_id,和实际文件列表做差集,把断链的样本清出去。空标注图根据情况处理:如果训练框架支持空图,保留作为背景样本;如果不支持,就移除并在数据列表里标记。
5.5 现象:换机器训练后精度下降,看起来像玄学
原因多数是环境相关:OpenCV 版本变了,解码出来的像素有细微差异;也可能是换了机器后预训练权重路径没配,训练从随机初始化开始,而不是沿用之前的权重。解决方法是固定 requirements 版本,把预训练权重和训练出的 best.pt 都放进项目目录,不要依赖全局缓存。排查时先把验证阶段的可视化图打出来,对比相同一张图的预测框是否稳定,能快速缩小范围。这类问题不是模型的问题,环境一致性和权重路径先确认,别急着调学习率。
6. 训练前的质量门禁:三格式可视化验证与多任务进阶
6.1 画框可视化:先看 50 张再谈训不训
from PIL import Image, ImageDraw def draw_boxes(img_path, boxes, out_path): img = Image.open(img_path).convert("RGB") draw = ImageDraw.Draw(img) for box in boxes: x1, y1, x2, y2 = [int(v) for v in box] draw.rectangle([x1, y1, x2, y2], outline=(0, 255, 0), width=3) draw.text((x1, y1 - 10), "cow", fill=(255, 0, 0)) img.save(out_path)这个脚本不需要训练,直接在数据集上跑。我习惯抽查三个地方:密集羊群边缘的羊有没有漏标、牛栏里背对镜头的牛框是否夹住身体、远景小目标的框有没有只框住半边。漏标比错标更危险,因为训练时漏标的区域会被当成背景,模型学出来就是漏检。抽查 50 张没问题再进训练,比训练完再返工省一个晚上。
6.2 用抽检脚本监督三份标签的一致性
def get_iou(a, b): ix1, iy1 = max(a[0], b[0]), max(a[1], b[1]) ix2, iy2 = min(a[2], b[2]), min(a[3], b[3]) inter = max(0, ix2 - ix1) * max(0, iy2 - iy1) area_a = (a[2] - a[0]) * (a[3] - a[1]) area_b = (b[2] - b[0]) * (b[3] - b[1]) union = area_a + area_b - inter return inter / union if union > 0 else 0 # xml_box 来自 2.2,yolo_box 来自 2.3,json_box 来自 2.4 iou_xy = get_iou(xml_box, yolo_box) iou_js = get_iou(xml_box, json_box) if iou_xy < 0.9 or iou_js < 0.9: print(f"[error] img {img_id} 三格式不一致 IOU: {iou_xy:.3f} {iou_js:.3f}")三份标签对同一个框应视为同一目标进行匹配,最快捷方式是中心点最近匹配。IOU 阈值用 0.9,低于它就人工介入。这一步能在训练前把噪音清掉,是我每次拿到新数据集都要走一遍的流程。
6.3 进阶:把三种标注喂给多任务网络
三份标签的长期价值在于:TXT 用来做主检测任务,JSON 里的附加字段做属性或遮挡的辅助头,XML 做跨框架评测或导出。我现在做一个牧场项目,习惯是保留三份原始标签不删,训练脚本只吃 TXT,评测脚本吃 XML,交付接口吃 JSON。将来要加“羊只计数 + 个体状态识别”,直接在 JSON 上加一个 key,不用再动标注流水线。这个习惯帮我省过好几次返工——当你以为 XML 删了就删了,结果评测方突然要 PASCAL VOC 格式的结果,就得重新转。保留原格式就是后悔药,这个原则我现在放在所有数据项目的第一位。希望帮到你。
本文还有配套的精品资源,点击获取