一、介绍实战项目

        前面学了这么多大家已经完全掌握YOLO11的基本训练预测的流程了,那么现在用一个稍微正的项目来实战一下:《基于DOTA遥感影像数据集进行旋转目标体检测》

1、旋转体目标检测

ultralytics官网对OBB的介绍文档:https://docs.ultralytics.com/tasks/obb/

        我们之前学得YOLO11普通目标检测任务只需要水平、垂直方向的检测框框住大概的物体就行;但是YOLO_OBB是专门用旋转框检测带角度倾斜的目标的,遥感影像特征模糊而且很多都是斜的,就要用这个任务模型

 

2、DOTA遥感影像数据集

数据集官网:https://captain-whu.github.io/DOTA/dataset.html

        我们知道模型学习本身就要海量数据集,而且遥感数据的目标很小,特征比较模糊,更是需要海量数据,而DOTA数据集由‌武汉大学遥感国家重点实验室‌的夏桂松老师和‌华中科技大学电信学院‌的白翔老师联合整理并标注。正好这个数据集包含2806张高分辨率遥感图像和188282个标注物体,是目前最大、最全的遥感图像数据集之一,拿他练手一点毛病没有。

二、数据准备

1、官网下载数据

        去到DOTA数据集官网拉到底,对于我们小白简单的练手,就下载那个1.0版本就行,第一行是百度云方式下载,第二行是谷歌网盘下载(要把train set \ test set \ validation set 3个都下载了)

2、下载后提取文件

然后记住我的步骤,提取我们要用的数据整理

  • 1、这里注意:“
    • 在YOLO训练时,我依旧采用我习惯的文件目录结构
    • 但是在训练之前我们还需要用脚本对图片进行一些操作,这需要我们先设置成下图这样的目录结构,注意文件夹名也要一样,因为等会讲到的这个工具的代码已经写好了,所以没办法,我一开始弄成了上面的结构结果还是改了
  • 2、然后我们解压、打开下载的数据集文件
    • 应该跟我这个结构一样,那么所有图片images的数据我们都要!
    • 然后标签文件,我们只要labelTxt-v1.0别的都不要!

  • 3、images里的文件全部解压放一起
    • 里面的part1、part2、part3.....是因为一个图片文件夹整个压缩还是很大,作者就把每个文件夹又分割几份来压缩,所以我们解压完放一起就行了。但是注意train、test、val还是要区分开放的
    • 【all_images】:可以把train、test、val的图片全部放一起,一共2806张

3、分割图片

DOTA图片分割工具地址:https://github.com/CAPTAIN-WHU/DOTA_devkit

        如果你点开一些DOTA数据集中的图像,你会发现,图片尺寸都是不一样大的,并且分辨率极高,这样的图像不适合直接用来训练,因此需要我们先做切割,然后再用于训练和检验。我们首先需要去上面链接下载DOTA数据集的工具箱下载下来

(为什么不做直接数据增强、归一化.....因为这些遥感影像本来拍物体就很小了,我们希望尽可能让图片清晰,宁愿分割成多张局部的图)

        然后强调,用这个工具脚本之前,确保你的结构是这样,【all_images】和【all_txt】没有没关系,train、test、val这三个文件及以及里面的层级目录一定跟下图要一样

下载后,pip下载一下【shapely】这个工具包,用于分割重组图片的

        然后我们创建一个【DOTA_split】目录和【DOTA】一样,用于接收保存分割后的图片和标签文件

        然后注意【ImgSplit_multi_process.py】这个脚本用来分割【train训练集】和【val验证集】,因为他两是有labelTxt标签文件的,所以这个脚本会自动根据【train】、【val】路径,读取到里面的【images】和【labelTxt】两个文件夹(不需要你指定)来对照着一起分割

        然后注意【SplitOnlyImage_multi_process.py】这个脚本专门用来分割【test训练集】,因为训练集下面没有【labelTxt标签文件】,只用分割图片。(但是这个脚本我们要专门指定【test】下的【images】文件夹,不然会找不到图片,而且输出保存的路径也要提前手动创建)

还有几个提示:

  • 1、路径不要有中文,可以用相对路径,test的输入输出记得指定到images
  • 2、这个不是报错,只是一些没用的警告,不影响

4、转换txt里的格式

现在我们还要改txt的文件格式:

  • 1、YOLO11的OBB要求的标签数据格式是:
    • 【object_id x1 y1 x2 y2 x3 y3 x4 y4】:物体类别的id、和4个点的xy坐标
  • 2、而DOTA的原始txt标签数据格式是:
    • 【x1 y1 x2 y2 x3 y3 x4 y4 object_name hard_level】:4个点的xy坐标、物体类别的id、被检测到的困难程度
    • 很明显像object_name就不符合yolo格式,yolo认物体类别只认0、1、2这样的数字,不认英文单词,所以要改

这里我写好2个脚本:

这个是稳妥版,修改完yolo格式的标签后,自动存放到同级目录下

你需要手动创建保留这个新文件夹而删除原来的txt文件夹

import os
from pathlib import Path
import cv2
import numpy as np

# ========= 你只改这三行 =========
IMG_DIR = Path(r"你的原始图片数据集目录")
SRC_LABEL_DIR = Path(r"你的原始txt标签文件目录")       # DOTA原始标注
DST_LABEL_DIR = Path(r"你希望改好的YOLO-OBB格式txt文件放到哪")  # 输出(归一化)
# =================================

DST_LABEL_DIR.mkdir(parents=True, exist_ok=True)

IMG_EXTS = [".png", ".jpg", ".jpeg", ".tif", ".tiff", ".bmp"]

# DOTA 1.0 类别映射(按你之前给的顺序)
CLASS_MAP = {
    "plane": 0,
    "ship": 1,
    "storage-tank": 2,
    "baseball-diamond": 3,
    "tennis-court": 4,
    "basketball-court": 5,
    "ground-track-field": 6,
    "harbor": 7,
    "bridge": 8,
    "large-vehicle": 9,
    "small-vehicle": 10,
    "helicopter": 11,
    "roundabout": 12,
    "soccer-ball-field": 13,
    "swimming-pool": 14,
}

def imread_unicode(path: Path):
    """支持中文路径的读取"""
    data = np.fromfile(str(path), dtype=np.uint8)
    img = cv2.imdecode(data, cv2.IMREAD_COLOR)
    return img

def find_image_by_stem(stem: str):
    for ext in IMG_EXTS:
        p = IMG_DIR / f"{stem}{ext}"
        if p.exists():
            return p
    return None

def clamp01(x: float) -> float:
    if x < 0.0: return 0.0
    if x > 1.0: return 1.0
    return x

def convert_one(src_txt: Path, dst_txt: Path):
    stem = src_txt.stem
    img_path = find_image_by_stem(stem)
    if img_path is None:
        # 没找到同名图片,跳过
        return False, "miss_image"

    img = imread_unicode(img_path)
    if img is None:
        return False, "bad_image"

    h, w = img.shape[:2]

    out_lines = []
    with open(src_txt, "r", encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if not line or line.startswith("imagesource") or line.startswith("gsd"):
                continue

            parts = line.split()
            # DOTA:x1 y1 x2 y2 x3 y3 x4 y4 class difficult  (通常>=10列)
            if len(parts) < 10:
                continue

            coords = list(map(float, parts[:8]))
            cls_name = parts[8]
            if cls_name not in CLASS_MAP:
                continue

            cls_id = CLASS_MAP[cls_name]

            # 归一化:x/W, y/H
            x1, y1, x2, y2, x3, y3, x4, y4 = coords
            nx1, ny1 = clamp01(x1 / w), clamp01(y1 / h)
            nx2, ny2 = clamp01(x2 / w), clamp01(y2 / h)
            nx3, ny3 = clamp01(x3 / w), clamp01(y3 / h)
            nx4, ny4 = clamp01(x4 / w), clamp01(y4 / h)

            out_lines.append(
                f"{cls_id} {nx1:.6f} {ny1:.6f} {nx2:.6f} {ny2:.6f} {nx3:.6f} {ny3:.6f} {nx4:.6f} {ny4:.6f}"
            )

    with open(dst_txt, "w", encoding="utf-8") as f:
        f.write("\n".join(out_lines))

    return True, "ok"

def main():
    miss_image = 0
    bad_image = 0
    ok = 0

    for src_txt in SRC_LABEL_DIR.glob("*.txt"):
        dst_txt = DST_LABEL_DIR / src_txt.name
        success, reason = convert_one(src_txt, dst_txt)
        if not success:
            if reason == "miss_image":
                miss_image += 1
            elif reason == "bad_image":
                bad_image += 1
            continue
        ok += 1

    print("✅ 转换完成(DOTA -> YOLO-OBB 归一化)")
    print(f"  输出目录: {DST_LABEL_DIR}")
    print(f"  成功: {ok}")
    print(f"  缺少同名图片: {miss_image}")
    print(f"  图片读取失败: {bad_image}")

if __name__ == "__main__":
    main()

这个是方便版,不用创建新文件夹,直接在原文件内容更改

不过建议还是拿一两个文件副本试一下,因为生怕出点什么问题直接把你原txt的内容给毁了

import os

# -------------------------- 核心配置(确认和你的情况匹配) --------------------------
# 1. 类别映射(连字符格式,和你的DOTA标签一致)
object_mapping = {
    "plane": 0,
    "ship": 1,
    "storage-tank": 2,
    "baseball-diamond": 3,
    "tennis-court": 4,
    "basketball-court": 5,
    "ground-track-field": 6,
    "harbor": 7,
    "bridge": 8,
    "large-vehicle": 9,
    "small-vehicle": 10,
    "helicopter": 11,
    "roundabout": 12,
    "soccer-ball-field": 13,
    "swimming-pool": 14,
}
# 2. 你的原标签文件夹路径(直接改这个!)
label_dir = r"F:\我自己的毕设\YOLO_study\DOTA\DOTA_split\train\labelTxt"
# 3. 分块尺寸(和你分割图片的尺寸一致,默认1024)
split_size = 1024
# -----------------------------------------------------------------------------

def process_single_file(file_path):
    """处理单个txt文件,直接修改原文件内容"""
    # 读取原文件内容
    with open(file_path, 'r', encoding='utf-8') as f:
        lines = f.readlines()
    
    # 处理每一行,筛选有效内容并转换格式
    new_lines = []
    for line in lines:
        line = line.strip()
        # 跳过空行、元数据行(imagesource/gsd)
        if not line or line.startswith(("imagesource", "gsd")):
            continue
        
        data = line.split()
        # 过滤格式错误的行(DOTA标签必须是10个部分)
        if len(data) != 10:
            print(f"⚠️ {os.path.basename(file_path)}:跳过格式错误行 → {line}")
            continue
        
        # 1. 坐标归一化(像素值→0~1的相对值)
        coords = [str(float(coord) / split_size) for coord in data[:8]]
        # 2. 类别名转ID
        object_name = data[8]
        object_id = object_mapping.get(object_name)
        if object_id is None:
            print(f"⚠️ {os.path.basename(file_path)}:跳过未知类别 → {object_name}")
            continue
        
        # 3. 构造YOLO OBB格式行
        new_line = f"{object_id} " + " ".join(coords) + "\n"
        new_lines.append(new_line)
    
    # 直接写回原文件(覆盖原有内容)
    with open(file_path, 'w', encoding='utf-8') as f:
        f.writelines(new_lines)
    
    # 打印处理结果(区分有内容/空文件)
    if len(new_lines) > 0:
        print(f"✅ {os.path.basename(file_path)}:转换完成,有效标签数={len(new_lines)}")
    else:
        print(f"ℹ️ {os.path.basename(file_path)}:无有效标签,文件为空(0KB)")

def process_all_files(label_dir):
    """遍历目录下所有txt文件,批量处理"""
    # 先检查目录是否存在
    if not os.path.exists(label_dir):
        print(f"❌ 错误:标签目录不存在 → {label_dir}")
        return
    
    # 遍历所有txt文件
    for root, _, files in os.walk(label_dir):
        for file in files:
            if file.endswith('.txt'):
                file_path = os.path.join(root, file)
                process_single_file(file_path)

if __name__ == '__main__':
    # 安全提醒(必须先备份!)
    input("⚠️ 请确认已备份原labelTxt文件夹,按回车键继续(取消按Ctrl+C)...")
    
    # 执行批量处理
    print("\n开始转换标签格式(直接修改原文件)...")
    process_all_files(label_dir)
    print("\n🎉 所有文件处理完成!")

        另外,改好后的txt文件有的是0KB没内容的不要慌,不是脚本出问题帮你删了,而是原数据本来就没内容,因为我们前面分割了图片,有的图片可能只是单纯的一个背景,没有目标检测体,那就没有东西需要记录啊

5、最后,文件目录结构换成YOLO标准

现在一切准备完毕,可以把文件结构整理成YOLO标准了

        至于我加的all_images、all_txt你想加也可以加,我的想法是这两个文件夹后续我再添加别的图和标签到这,然后经过处理之后我再分别放到images和labels,可有可无

6、改写yaml文件

写过很多次了,这里就不多说了

        另外我把前面的【原DOTA数据】、【新DOTA数据】、【DOTA分割工具】都装到DOTA文件夹下的一个Datasets文件夹下,因为都归属于数据集,这样看着顺眼,然后我们的yaml文件、训练代码都可以直接放DOTA这一层总目录了

# 1. 数据集根路径(替换为你实际的Datasets文件夹绝对路径)
path: F:\我自己的毕设\YOLO_study\DOTA\Datasets\DOTA_split

# 2. 训练集/验证集图片路径(相对于path的相对路径)
train: images/train
val: images/val

# Classes for DOTA 1.0
names:
  0: plane
  1: ship
  2: storage tank
  3: baseball diamond
  4: tennis court
  5: basketball court
  6: ground track field
  7: harbor
  8: bridge
  9: large vehicle
  10: small vehicle
  11: helicopter
  12: roundabout
  13: soccer ball field
  14: swimming pool

三、开始训练

首先这个数据集非常庞大,我这里写了两篇关于这种数据集训练时拉满我们电脑GPU效率的文章,非常有用这里就不再说了:

https://blog.csdn.net/m0_73991249/article/details/157062151》workers多线程

https://blog.csdn.net/m0_73991249/article/details/157287641》调参加快速度

1、第一次 “炼丹”

        我的思路是这样,第一次我先啥也不做,以最快速度在本地把这些训练数据跑一次看看效果先,而且用的依旧是yolo11-obb.pt这个官方模型,所以这里train.py没有加任何提高学习效果的参数,也没有调yaml结构

import torch
from ultralytics import YOLO


if __name__ == '__main__': # 这是为了Windows下开启workers>0 的硬性条件,workers>0就加快速度
    torch.multiprocessing.freeze_support() # 解决多进程报错问题

    # 直接加载官方YOLO11n预训练模型
    # model = YOLO(r"F:\我自己的毕设\YOLO_study\DOTA\my_yaml\11\my_yolo11-obb.yaml").load("yolo11n-obb.pt")
    model = YOLO("yolo11n-obb.pt")

    results = model.train(
        data=r"F:\我自己的毕设\YOLO_study\DOTA\DOTA.yaml",
        epochs=50,
        imgsz=640,
        device=0,

        workers=6,  # ❗Windows 不要太多
        cache=True,  # ❗立刻关掉 disk cache
        batch=15,

        val=False,  # ❗打开验证(反而更稳定)
        plots=False,  # ❗关闭训练曲线
    )

        虽然依旧很久,耗了大概2、3个小时才跑完50轮1万多张图片的数据集,但是已经相对很快了,然后打开run/obb/train,可想而知结果不尽人意,遥感影像虽然已经切割,但是目标物体依旧很小很小,导致训练集学习效果本来就一般,验证集各个损失指数更是大的离谱,mAP也上升波动很大

        而且还有一个问题就是,车辆、船只、油罐、篮球场.....这些数据集占比太多太多,几乎每张图片都有这些目标,因此模型对他们学习得很好,但是相反桥梁、港口....这些数据集相对占比较小,每一batch会随机取一部分图片训练,这些占比小得目标的学习效果就很差

2、第二次 “炼丹”(慎选:搭建我自己的数据集)

这个操作各位可以慎重选择,并非要跟我一样

1)想法一:补强DOTA1.0里关于【海陆交通】的一些弱项类别数据

我的想法是:我决定在这个基础上再加一点数据,专门补充这几个难被检测的种类的图片

没被划掉的就是很难被检测的类别

        而且我专门用【官方yolo11n-obb.pt】反向对所有DOTA图片进行了一次【批量预测】,发现有很多图像数据预测的效果很差,于是我直接把DOTA1.0里这些训练效果好的图片删了,整理留下了预测效果不好的数据集,专门留给yolo学习训练用

2)想法二:补充其他类别

        我的初衷只是想找DOTA1.0里训练效果不好的那几类数据,结果意外发现了另一份超大数据集【YOLO_DIOR】,我后续会把【DIOR】和【我整合过的整个数据集】分享出来

于是我把这个数据集下载下来,然后我留意到里面有这么几个我需要的类别:

  • 1、我DOTA1.0里原本就需要补充的【"bridge桥梁"、"harbor港口"】
  • 2、DIOR里特有的【"Expressway-Service-area高速服务区"、"Expressway-toll-station高速收费站"、"overpass立交桥高架桥"、"trainstation火车站"】
    • ("bridge桥梁"特指跨水面上的、"overpass立交桥高架桥"是路面上的)

于是我最终通过人工+脚本的方式筛选整合,合并成了我要的【集合大数据集】

  • 我还自己另外闲着没事干自己加入了【crossroad十字路口】这一类别的数据集

3)想法三:手动爬刁钻数据集

        然后我发现,【roundabout环岛交叉路口】和【crossroad十字路口】去网上根本找不到任何一份公开数据集,国内国外所有网站除了暗网我都找了一遍没有,只能我自己爬虫获取。于是我下了一个爬百度图库的脚本来分别爬取500张图片

import os
import requests
import re
import time
import random

# 这里save_dir切换你自己要保存到的文件夹地址
def download_images(keyword, target_count=300, save_dir=r"F:\我自己的毕设\YOLO_study\DOTA\Datasets\my_new_data\crossroad"):
    # 创建保存文件夹
    if not os.path.exists(save_dir):
        os.makedirs(save_dir)
        print(f"📁 文件夹已创建: {save_dir}")

    # 伪装 Header
    headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.69 Safari/537.36',
        'Referer': 'https://image.baidu.com/'
    }

    url = 'https://image.baidu.com/search/acjson'
    count = 0
    page = 0

    print(f"🚀 开始爬取关键词: 【{keyword}】,目标: {target_count} 张")

    while count < target_count:
        # 构造参数
        params = {
            'tn': 'resultjson_com',
            'logid': '1122334455',
            'ipn': 'rj',
            'ct': '201326592',
            'is': '',
            'fp': 'result',
            'queryWord': keyword,
            'cl': '2',
            'lm': '-1',
            'ie': 'utf-8',
            'oe': 'utf-8',
            'adpicid': '',
            'st': '',
            'z': '',
            'ic': '',
            'hd': '',
            'latest': '',
            'copyright': '',
            'word': keyword,
            's': '',
            'se': '',
            'tab': '',
            'width': '',
            'height': '',
            'face': '',
            'istype': '',
            'qc': '',
            'nc': '1',
            'fr': '',
            'expermode': '',
            'force': '',
            'cg': '',
            'pn': page * 30,  # 翻页
            'rn': '30',  # 每页数量
            'gsm': hex(page * 30)[2:],
        }

        try:
            # 请求 JSON 数据
            res = requests.get(url, headers=headers, params=params, timeout=10)
            res.encoding = 'utf-8'

            try:
                json_data = res.json()
            except:
                print("⚠️ 解析 JSON 失败,跳过此页")
                page += 1
                continue

            data_list = json_data.get('data', [])

            if not data_list:
                print("❌ 没有更多数据了,结束爬取。")
                break

            for item in data_list:
                if count >= target_count:
                    break

                if 'thumbURL' not in item:
                    continue

                # 优先尝试获取高清原图链接 (replaceUrl 通常包含未加密的原图链接)
                img_url = item.get('thumbURL')  # 默认用缩略图保底

                # 尝试找高清图
                if item.get('replaceUrl') and len(item['replaceUrl']) > 0:
                    obj_candidate = item['replaceUrl'][0].get('objurl')
                    if obj_candidate:
                        img_url = obj_candidate

                # 下载图片
                try:
                    # 获取图片内容
                    img_res = requests.get(img_url, headers=headers, timeout=10)
                    if img_res.status_code == 200:
                        file_ext = os.path.splitext(img_url)[1]
                        if not file_ext or len(file_ext) > 5:
                            file_ext = ".jpg"  # 默认后缀

                        # 命名格式: keyword_00001.jpg
                        file_name = f"{save_dir}/{count:05d}{file_ext}"

                        with open(file_name, 'wb') as f:
                            f.write(img_res.content)

                        print(f"✅ [{count + 1}/{target_count}] 下载成功: {file_name}")
                        count += 1
                    else:
                        print(f"⚠️ 下载失败 (状态码 {img_res.status_code}): {img_url}")
                except Exception as e:
                    print(f"⚠️ 下载出错: {e}")

            page += 1
            # 随机休眠一下,防止被封 IP
            time.sleep(random.uniform(0.5, 1.5))

        except Exception as e:
            print(f"❌ 网络请求错误: {e}")
            break

    print(f"\n任务完成!共下载 {count} 张图片,保存在 '{save_dir}' 文件夹下。")
    print("❗请务必人工筛选一遍,删除无关图片或带水印严重的图片!")


# 运行爬虫
if __name__ == "__main__":
    download_images("城市十字路口俯视图", target_count=500)

当然还需要人为手动删除,因为百度图库里有很多不相干的乱七八糟的狗屎图片数据

4)想法四:手动标注、咸鱼找人标注

        那么在我删了无用的图片后,还剩下400来张图片,那么这些自己找到的图片当然是没有对应标签文件的,还需要我们自己标注,那么这是一个大工程,我们要速战速决

  • 【自己手动标注:不太推荐,浪费时间】
    • 如果你就是舍不得钱,那就只能自己标注了,这里疯狂强烈推荐【X-Anylabeling】,当然如果你钟爱【label-studio】也不是不行,label-studio对于obb旋转框是用自定义点4个点来标框;X-Anylabeling则是标准矩形框然后靠Z/V键旋转
    • 【X-Anylabeling】的好处就是你可以先标注20、30张图训练一个小模型,然后再用你的小模型自动化识别标注大数据集
    • 我的教程文章在这:https://blog.csdn.net/m0_73991249/article/details/157394947?spm=1001.2014.3001.5501

  • 【找咸鱼卖家:强烈推荐!!非常推荐!!】
    • 不是广告,只是告诉各位好方法,基本价格是0.08一框,我的400多个框就花了35块多,而且效率很高!!
    • 缺点就是可能标的框不是很按你预想的来,或者漏一些图没标上,这里建议你跟卖家微信及时沟通,这些卖家基本都是学生,没那么黑心,还是有售后的
    • 我们要做的就是根据他给的标注,反向检查一下这些数据集标的框对不对,脚本如下:
      import os
      from pathlib import Path
      import cv2
      import numpy as np
      
      # ======== 路径改这里 ========
      IMG_DIR = Path(r"你的图片文件目录")
      LBL_DIR = Path(r"你的标签文件目录")
      OUT_DIR = Path(r"你希望检查结果图片输出到哪")
      # ===========================
      
      OUT_DIR.mkdir(parents=True, exist_ok=True)
      IMG_EXTS = [".png", ".jpg", ".jpeg", ".tif", ".tiff", ".bmp"]
      
      COLORS = [
          (255, 0, 0), (0, 255, 0), (0, 0, 255),
          (255, 255, 0), (255, 0, 255), (0, 255, 255)
      ]
      
      def imread_unicode(path: Path):
          data = np.fromfile(str(path), dtype=np.uint8)
          img = cv2.imdecode(data, cv2.IMREAD_COLOR)
          return img
      
      def imwrite_unicode(path: Path, img):
          ext = path.suffix.lower()
          ok, buf = cv2.imencode(ext, img)
          if not ok:
              return False
          buf.tofile(str(path))
          return True
      
      def find_image_by_stem(stem: str):
          for ext in IMG_EXTS:
              p = IMG_DIR / f"{stem}{ext}"
              if p.exists():
                  return p
          return None
      
      def draw_poly(img, pts, cls_id):
          color = COLORS[cls_id % len(COLORS)]
          pts = np.array(pts, dtype=np.int32).reshape((-1, 1, 2))
          cv2.polylines(img, [pts], isClosed=True, color=color, thickness=2)
          x, y = pts[0, 0]
          cv2.putText(img, str(cls_id), (int(x), int(y) - 5),
                      cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2)
      
      def is_normalized(coords):
          # coords: list[float] length=8
          mx = max(coords)
          mn = min(coords)
          # 归一化通常在 [0,1],给点余量防止写成 1.0001
          return (mn >= -0.01) and (mx <= 1.5)
      
      def main():
          label_files = sorted(LBL_DIR.glob("*.txt"))
          if not label_files:
              print(f"❌ 没找到标注txt: {LBL_DIR}")
              return
      
          done, miss_img, bad_img = 0, 0, 0
      
          for txt_path in label_files:
              stem = txt_path.stem
              img_path = find_image_by_stem(stem)
              if img_path is None:
                  miss_img += 1
                  continue
      
              img = imread_unicode(img_path)
              if img is None:
                  bad_img += 1
                  continue
      
              h, w = img.shape[:2]
      
              with open(txt_path, "r", encoding="utf-8") as f:
                  for line in f:
                      parts = line.strip().split()
                      if len(parts) != 9:
                          continue
      
                      cls_id = int(parts[0])
                      coords = list(map(float, parts[1:]))
      
                      if is_normalized(coords):
                          # 归一化 → 像素
                          coords = [
                              coords[0] * w, coords[1] * h,
                              coords[2] * w, coords[3] * h,
                              coords[4] * w, coords[5] * h,
                              coords[6] * w, coords[7] * h,
                          ]
      
                      pts = [
                          (coords[0], coords[1]),
                          (coords[2], coords[3]),
                          (coords[4], coords[5]),
                          (coords[6], coords[7]),
                      ]
                      draw_poly(img, pts, cls_id)
      
              out_path = OUT_DIR / img_path.name
              imwrite_unicode(out_path, img)
              done += 1
      
          print("✅ 可视化完成")
          print(f"  输出目录: {OUT_DIR}")
          print(f"  成功处理: {done}")
          print(f"  找不到同名图片: {miss_img}")
          print(f"  图片读取失败: {bad_img}")
      
      if __name__ == "__main__":
          main()

5)想法五:区分需要切割和不需要切割的图

这么一堆混合数据集中,我还留意到:并非所有图片都需要切割

        前面我们不是对DOTA1.0的图像进行切割了吗,后面我仔细发现,有的图片本身就是低空拍的近景,目标物体就在正中心很大一块,而且图片已经放大得很模糊了,如果我们再切割,就成了四分五裂不知道是啥的东西

        所以我又闲着没事干手动区分了【很高空拍的微小目标,需要切割的(need_split)】【低空拍的不需要切割(norm)】的两种图,并经过一系列标签文件格式转换和文件整理,最终把【很高空拍的微小目标,需要切割的(need_split)】才进行了切割,其他图片保持原样

6)想法六:然后我再再再对特殊类别进行数据增强

        前面我不是说了我手动爬了两类【roundabout环岛交叉路口】和【crossroad十字路口】数据嘛,然后最终只有400多份,对于整个将近8000多的图片数据集远远不够,我怕训练时还是因为每一batch的占比太少而学习不好

        于是我把他们单独进行各种数据增强,在原本400份的基础上翻了6倍,得到了2400多份不一样的数据,脚本如下

import os
import cv2
import numpy as np
from pathlib import Path
from tqdm import tqdm

# ========= ⚠️ 请务必核对以下四个路径 ⚠️ =========
# 1. 你的原图位置
SRC_IMG_DIR = Path(r"F:\我自己的毕设\YOLO_study\DOTA\Datasets\Temp_Final_Pool\images")
# 2. 你之前转好的 YOLO OBB 标签位置 (请检查文件夹名是否叫这个)
SRC_LBL_DIR = Path(r"F:\我自己的毕设\YOLO_study\DOTA\Datasets\Temp_Final_Pool\labelTxt")

# 3. 增强后的图片输出位置
DST_IMG_DIR = Path(r"F:\我自己的毕设\YOLO_study\DOTA\Datasets\Augmented_Final_Pool\images")
# 4. 增强后的标签输出位置
DST_LBL_DIR = Path(r"F:\我自己的毕设\YOLO_study\DOTA\Datasets\Augmented_Final_Pool\labels")
# ===============================================

DST_IMG_DIR.mkdir(parents=True, exist_ok=True)
DST_LBL_DIR.mkdir(parents=True, exist_ok=True)


def imread_unicode(path):
    data = np.fromfile(str(path), dtype=np.uint8)
    return cv2.imdecode(data, cv2.IMREAD_COLOR)


def imwrite_unicode(path, img):
    ext = os.path.splitext(path)[1]
    result, nparray = cv2.imencode(ext, img)
    if result:
        nparray.tofile(str(path))


def augment_obb(img, lines, mode):
    aug_img = img.copy()
    aug_lines = []
    h, w = img.shape[:2]

    for line in lines:
        parts = line.strip().split()
        if len(parts) < 9: continue
        cls_id = parts[0]
        coords = np.array([float(x) for x in parts[1:]]).reshape(4, 2)
        new_coords = coords.copy()

        if mode == 'h_flip':
            new_coords[:, 0] = 1.0 - coords[:, 0]
            if aug_img is not None: aug_img = cv2.flip(img, 1)
        elif mode == 'v_flip':
            new_coords[:, 1] = 1.0 - coords[:, 1]
            if aug_img is not None: aug_img = cv2.flip(img, 0)
        elif mode == 'hv_flip':
            new_coords[:, 0] = 1.0 - coords[:, 0]
            new_coords[:, 1] = 1.0 - coords[:, 1]
            if aug_img is not None: aug_img = cv2.flip(img, -1)
        elif mode == 'bright':
            aug_img = cv2.convertScaleAbs(img, alpha=1.2, beta=30)
        elif mode == 'dim':
            aug_img = cv2.convertScaleAbs(img, alpha=0.8, beta=-20)

        flat_coords = new_coords.flatten()
        new_line = f"{cls_id} " + " ".join([f"{x:.6f}" for x in flat_coords])
        aug_lines.append(new_line)

    return aug_img, aug_lines


def main():
    img_list = list(SRC_IMG_DIR.glob("*.*"))
    print(f"🌟 任务启动!原图数量: {len(img_list)}")

    # 调试:先检查一下第一个标签文件是否存在
    if len(img_list) > 0:
        test_lbl = SRC_LBL_DIR / (img_list[0].stem + ".txt")
        print(f"DEBUG: 正在尝试匹配标签路径: {test_lbl}")
        if not test_lbl.exists():
            print(f"❌ 警告:在该路径下找不到标签文件!请检查 SRC_LBL_DIR 设置。")
        else:
            print(f"✅ 成功找到标签,开始执行增强逻辑...")

    ok_count = 0
    for img_p in tqdm(img_list):
        if img_p.suffix.lower() not in ['.png', '.jpg', '.jpeg', '.tif']: continue

        img = imread_unicode(img_p)
        lbl_p = SRC_LBL_DIR / (img_p.stem + ".txt")

        # 保存原图
        imwrite_unicode(DST_IMG_DIR / img_p.name, img)

        if lbl_p.exists():
            with open(lbl_p, 'r', encoding='utf-8') as f:
                lines = f.readlines()

            # 保存原标签
            with open(DST_LBL_DIR / (img_p.stem + ".txt"), 'w', encoding='utf-8') as f:
                f.writelines(lines)

            # 生成增强版本
            modes = ['h_flip', 'v_flip', 'hv_flip', 'bright', 'dim']
            for mode in modes:
                aug_img, aug_lines = augment_obb(img, lines, mode)
                new_name = f"{img_p.stem}_{mode}"
                imwrite_unicode(DST_IMG_DIR / (new_name + img_p.suffix), aug_img)
                with open(DST_LBL_DIR / (new_name + ".txt"), 'w', encoding='utf-8') as f:
                    f.write("\n".join(aug_lines))
            ok_count += 1
        else:
            # 只有没有标签的图才会走到这里
            pass

    print(f"\n🎉 增强完成!")
    print(f"✅ 成功对 {ok_count} 张带标签的图片进行了增强(各生成5个新版本)")
    print(f"📂 最终图片总数: {len(list(DST_IMG_DIR.glob('*.*')))}")
    print(f"📂 最终标签总数: {len(list(DST_LBL_DIR.glob('*.txt')))}")


if __name__ == "__main__":
    main()

7)想法七:最后,清理大量空白目标

最后最后最后最后最后

        我还发现,整个数据集里,除了test测试集,train/val的图像对应的空白背景图竟然有将近1500多份!!这么多空白数据,一来占用我的电脑空间、二来增加模型训练的时间,每一批(Batch)喂给模型的数据里,有一大半是空的。虽然 YOLO 会算 Loss 为 0,但这确实浪费了 GPU 的算力,导致模型收敛变慢(需要跑更多 Epoch 才能学好)

        但是不能把它们全删了,因为他们的存在是相当于 “错误案例”,比如你教育学生:“1+1=100、2+2=999是错的计算”,这也是一种学习资料

于是我又搞了个脚本,扫描出这些图片和txt标签文件,然后随机删除一部分

import os
import random
from pathlib import Path
from tqdm import tqdm

# ================== ⚙️ 设置区域 ==================
# 你的训练集位置 (通常只需要清理 train,val/test 可以不动或少动)
TARGET_IMG_DIR = Path(r"F:\我自己的毕设\YOLO_study\CZM_NewRS\Datasets\My_New_RS_Dataset\all_images")
TARGET_LBL_DIR = Path(r"F:\我自己的毕设\YOLO_study\CZM_NewRS\Datasets\My_New_RS_Dataset\all_labels")

# 你想保留多少张空图? (建议设置为有目标图片数量的 10%-20%)
# 或者你可以直接写死一个数字,比如 1500
KEEP_EMPTY_COUNT = 900


# =================================================

def main():
    print("🚀 开始扫描数据集...")

    img_files = list(TARGET_IMG_DIR.glob("*.*"))
    empty_samples = []
    has_object_samples = []

    # 1. 区分“有目标”和“无目标”
    for img_p in tqdm(img_files):
        lbl_p = TARGET_LBL_DIR / (img_p.stem + ".txt")

        is_empty = True
        if lbl_p.exists():
            with open(lbl_p, 'r') as f:
                lines = [l.strip() for l in f.readlines() if l.strip()]
                if len(lines) > 0:
                    is_empty = False

        if is_empty:
            empty_samples.append(img_p)
        else:
            has_object_samples.append(img_p)

    total_count = len(img_files)
    empty_count = len(empty_samples)
    obj_count = len(has_object_samples)

    print(f"\n📊 当前数据分布:")
    print(f"✅ 有目标样本: {obj_count} 张")
    print(f"⚪ 纯背景样本: {empty_count} 张 (占比 {empty_count / total_count:.1%})")

    # 2. 判断是否需要删除
    if empty_count <= KEEP_EMPTY_COUNT:
        print(f"\n✨ 当前空图数量 ({empty_count}) 少于设定阈值 ({KEEP_EMPTY_COUNT}),无需清理!")
        return

    # 3. 执行随机删除
    print(f"\n✂️ 准备执行清理...")
    print(f"   目标保留空图数: {KEEP_EMPTY_COUNT}")
    print(f"   将要删除空图数: {empty_count - KEEP_EMPTY_COUNT}")

    # 随机打乱后切片
    random.shuffle(empty_samples)
    to_delete = empty_samples[KEEP_EMPTY_COUNT:]  # 保留前N个,删除后面的

    delete_count = 0
    for img_p in tqdm(to_delete, desc="Deleting Excess"):
        # 删除图片
        os.remove(img_p)
        # 删除对应的标签(如果有且为空)
        lbl_p = TARGET_LBL_DIR / (img_p.stem + ".txt")
        if lbl_p.exists():
            os.remove(lbl_p)
        delete_count += 1

    print("\n" + "=" * 40)
    print(f"🗑️ 清理完成!共删除了 {delete_count} 组纯背景文件。")
    print(f"🏁 最终训练集状态: 有目标 {obj_count} + 纯背景 {KEEP_EMPTY_COUNT} = {obj_count + KEEP_EMPTY_COUNT} 张")
    print("=" * 40)


if __name__ == "__main__":
    main()

最后最后别忘了整理分类

        整个数据集整理完毕,如果你也有一份刚刚整理完的数据集,别忘了将他们分类成【train】、【val】、【test】,脚本如下

import os
import shutil
import random
from pathlib import Path
from tqdm import tqdm

# ================== 🛠️ 路径配置 ==================
# 根目录
BASE_DIR = Path(r"F:\我自己的毕设\YOLO_study\CZM_NewRS\Datasets\My_New_RS_Dataset")

# 源文件夹
SRC_IMG = BASE_DIR / "all_images"
SRC_LBL = BASE_DIR / "all_labels"

# 目标文件夹
DIRS = {
    'train_img': BASE_DIR / "train" / "images",
    'train_lbl': BASE_DIR / "train" / "labels",
    'val_img': BASE_DIR / "val" / "images",
    'val_lbl': BASE_DIR / "val" / "labels",
    'test_img': BASE_DIR / "test" / "images"
}

# 设定随机种子,确保结果可复现(可选)
random.seed(42)


# =================================================

def setup_dirs():
    """创建标准的YOLO训练结构"""
    for path in DIRS.values():
        path.mkdir(parents=True, exist_ok=True)


def main():
    setup_dirs()

    # 1. 获取所有图片
    all_image_files = [f for f in SRC_IMG.glob("*.*") if f.suffix.lower() in ['.png', '.jpg', '.jpeg', '.tif', '.bmp']]

    test_list = []
    train_val_list = []

    # 2. 预分类:带 "test" 的分一拨,剩下的分一拨
    for img_p in all_image_files:
        if "test" in img_p.name.lower():
            test_list.append(img_p)
        else:
            train_val_list.append(img_p)

    print(f"📊 扫描完毕:")
    print(f"🔎 发现测试集图片 (带test关键字): {len(test_list)}")
    print(f"📦 待分配训练/验证图片: {len(train_val_list)}")

    # 3. 处理测试集 (只动图片)
    print("\n🚚 正在搬运测试集...")
    for img_p in tqdm(test_list, desc="Test Moving"):
        shutil.move(str(img_p), str(DIRS['test_img'] / img_p.name))

    # 4. 随机打乱训练/验证集并切分 (7:3)
    random.shuffle(train_val_list)
    split_idx = int(len(train_val_list) * 0.7)
    train_set = train_val_list[:split_idx]
    val_set = train_val_list[split_idx:]

    def move_files(file_list, target_img_dir, target_lbl_dir, desc):
        count = 0
        missing_lbl = 0
        for img_p in tqdm(file_list, desc=desc):
            # 搬运图片
            shutil.move(str(img_p), str(target_img_dir / img_p.name))

            # 寻找对应标签
            lbl_p = SRC_LBL / (img_p.stem + ".txt")
            if lbl_p.exists():
                shutil.move(str(lbl_p), str(target_lbl_dir / lbl_p.name))
                count += 1
            else:
                # 如果没有标签,创建一个空的,保证YOLO不报错
                open(target_lbl_dir / (img_p.stem + ".txt"), 'w').close()
                missing_lbl += 1
        return count, missing_lbl

    # 5. 执行搬运
    print(f"\n🚚 正在分配训练集 (70% = {len(train_set)})...")
    t_ok, t_miss = move_files(train_set, DIRS['train_img'], DIRS['train_lbl'], "Train Processing")

    print(f"\n🚚 正在分配验证集 (30% = {len(val_set)})...")
    v_ok, v_miss = move_files(val_set, DIRS['val_img'], DIRS['val_lbl'], "Val Processing")

    # 6. 最终结果报告
    print("\n" + "=" * 40)
    print("✅ 任务圆满完成!你的数据集已就绪!")
    print(f"📁 Train: {len(train_set)} 对 (其中补齐空标签: {t_miss})")
    print(f"📁 Val:   {len(val_set)} 对 (其中补齐空标签: {v_miss})")
    print(f"📁 Test:  {len(test_list)} 张图片")
    print("🚀 现在你可以直接去配置 yaml 文件开始训练了!")
    print("=" * 40)


if __name__ == "__main__":
    main()

【数据集地址链接】

我上面说了这么多,只是想分享一下我对整合这个数据集的思路和心得,各位不要跟着任何一步操作,因为纯浪费时间很SB,但我就是这么一个SB,我已经把数据整理好了,这里各位拿去直接用就行了:

拓展:AutoDL加快训练(建议试一下)

        那么可想而知,这么大的一个数据集,我们的电脑训练起来最快最快也得差不多一整天,尽管我的电脑也有GPU,也拉满了一切配置,但终究是一整天甚至两天时间训练(电脑自动休眠的时候就停止训练,知道你点开电脑它才接着训练)

1)配置

        于是我们需要去【AutoDL】租一些有多个GPU、内存缓存空间大、CPU性能屌爆炸的服务器来训练我们的模型,地址在这:https://www.autodl.com/market/list

然后如何配置使用我这里就不说了,推荐这篇文章,我就跟着他弄成功的:https://blog.csdn.net/qq_44242804/article/details/151799953?ops_request_misc=&request_id=&biz_id=102&utm_term=AutoDL%E8%B7%91%E4%B8%AA%E4%BA%BAyolo%E9%A1%B9%E7%9B%AE&utm_medium=distribute.pc_search_result.none-task-blog-2~all~sobaiduweb~default-2-151799953.142^v102^pc_search_result_base4https://blog.csdn.net/qq_44242804/article/details/151799953?ops_request_misc=&request_id=&biz_id=102&utm_term=AutoDL%E8%B7%91%E4%B8%AA%E4%BA%BAyolo%E9%A1%B9%E7%9B%AE&utm_medium=distribute.pc_search_result.none-task-blog-2~all~sobaiduweb~default-2-151799953.142^v102^pc_search_result_base4

2)上传

配置完后,我们就要接着配置yolo这些环境了,环境也弄好了就可以上传我们的项目压缩包了

首先如果数据很庞大,那么我们需要先把整个文件夹【压缩】,然后再上传

然后注意,上传的时候我们一定要上传到【/root/autodl-tmp】这层目录!!!因为

  • AutoDL用的是Ubuntu的Linux系统,【/root】这一层是系统盘,通常只有 30GB
    • 像我之前有个压缩包已经占了 26GB,如果直接在这里解压,空间瞬间就会爆满,导致服务器卡死甚至无法开机!
    • 而且因为压缩包太大超过系统盘可用空间,他会给你硬生生截断一半你的数据,上传一个残缺的文件压缩包
  • 【/root/autodl-tmp】才是数据盘,至少有50G,我们需要放这里解压
  • 所以一定要先双击【/root/autodl-tmp】这层目录,然后再上传我们的压缩包

3)解压

上传成功后,接下来就是解压

  • 首先进入到【/root/autodl-tmp/】目录下,然后执行【unzip 你的压缩包】来解压
    • 【cd /root/autodl-tmp/】
    • 【unzip XXX.zip】
  • 然后去到你的yaml文件以及train.py这些文件,把路径改成autodl服务器系统的,也就是我们刚刚放的【/root/autodl-tmp/......】下
  • 记住!!!!一定要保存!!不然你刚刚的修改就没有成功!!!!
  • 然后就是进入项目目录、创建并激活有yolo的python虚拟环境、开始运行代码,就这么个流程
    • 【cd <你的项目根目录>】
    • 【conda create -n <你的虚拟环境自定义名> <python=版本号>】
    • 【conda activate <你刚创建的虚拟环境>】
      • 如果激活虚拟环境失败,就先执行一下【conda init】和【source /root/.bashrc】
    • 安装yolo工具包【pip install ultralytics】至此你已经有了完美的可运行环境了

4)运行代码

接下来就是运行代码

  • 以后每次运行代码的时候都是【进入项目根目录】、【激活虚拟环境】、【python train.py】就完事了

  • 如果中途因为断网等原因中断了模型训练也不用慌
    • 新创建一个【resume_train.py】
    • 重新写一个train代码,只不过
      • model要选择你上一次训练生成的模型里的【last.pt】
      • (重要!!!!)设置参数:【resume=True】,否则还是从0开始
      • 其他啥参数也别写,device参数可写可不写吧,除非你换了设备配置
        import torch
        from ultralytics import YOLO
        
        # ⚠️ 指向刚才确认存活的 train5 下的 last.pt
        MODEL_PATH = "/root/autodl-tmp/CZM_NewRS/runs/obb/train2(autodl_1)/weights/last.pt"
        
        if __name__ == '__main__':
            torch.multiprocessing.freeze_support()
        
            # 加载这个存档
            model = YOLO(MODEL_PATH)
        
            # 开启 resume=True,它会自动恢复中断时的轮数、学习率
            # 所有参数(batch, workers, augment...)都会自动从存档里读出来!
            model.train(
                resume=True, 
                device=[0, 1]  # 双卡
            )
        
    • 然后还是老规矩,记得【保存】!!!
      • 然后又是那 “三行命令” 一条龙服务,只不过不是运行train.py了

5)代码

        这里提一下,千万别把数据增强开太大,否则辛辛苦苦训练完100多轮后会发现根本就是在用错的图像学习。。。尤其是perspective、mixup、copy_paste和mosaic这些参数能不开就别开吧,直接搞乱图像了

所以建议先跑个2-3 epoch先,看一下生成的train下的train_batch_0.jpg。。。。这些图片是否正常,正常的话在进行下一步完整训练

修改后的代码:

import torch
from ultralytics import YOLO

# ⚠️ 注意:AutoDL是Linux环境,路径要改!
# 把数据上传到 /root/autodl-tmp/ 下(强烈建议放这里,读写快)
DATA_YAML_PATH = "/root/autodl-tmp/你的yaml文件路径"
# 建议换成 s (Small) 或 m (Medium) 模型,AutoDL显卡完全跑得动,精度比n高很多
MODEL_PATH = "yolo11s-obb.pt"

if __name__ == '__main__':
    # Linux下通常不需要 freeze_support,但留着也不报错
    torch.multiprocessing.freeze_support()

    # 加载模型
    model = YOLO(MODEL_PATH)

    results = model.train(
        data=DATA_YAML_PATH,
        epochs=150,  # 多跑一点,100轮对于从头练可能刚收敛
        patience=60,  # 60轮不提升就停,省点钱
        imgsz=1024,  # 1280也可以,但1024是标准倍数,训练更稳

        # 【速度拉满配置】
        device=[0,1],  # ❗关键:我的服务器是两个GPU显卡,所以数组里0、1开启双卡并行训练!
        batch=-1,  # 如果设batch=16,则双卡合计32;设-1自动适配
        workers=16,  # 有40核CPU,大胆给!16-24都可以,数据加载飞快
        cache="ram",  # ❗有180G内存,直接把数据全读进RAM,速度起飞!
        amp=True,  # 混合精度训练,速度快显存占用少

        # 【增强参数微调:适当即可,拒绝卡通画】
        augment=True,
        hsv_h=0.015,  # 色相微调,保持不变
        hsv_s=0.3,  # ❗降下来!原0.7太高,导致色彩过饱和像动画片
        hsv_v=0.3,  # ❗降下来!原0.4太高,导致对比度太强
        # 【几何增强】
        degrees=5.0,  # 稍微给点旋转,OBB任务很需要
        translate=0.05,  # 稍微平移一下
        scale=0.5,  # 尺度缩放
        perspective=0.0,  # ✅ 必须0!透视太大,图片都变形了,0.0001都算大的
        # 【Mosaic 策略】
        mosaic=0.2,  # 开启马赛克增强
        mixup=0,  # 给一点点混合,不要太多
        copy_paste=0.0,   # ✅ 先关掉(后面再开)
        close_mosaic=10,  # ❗最后10轮关闭Mosaic即可,50轮太早了,浪费了增强效果

        # 📉【优化器】
        optimizer='SGD',  # 这种大数据集,SGD通常比AdamW后期泛化更好
        lr0=0.01,
        lrf=0.01,
        cos_lr=True,  # 余弦退火学习率,训练更丝滑
    )

结果也是非常优秀

 回忆知识点

3、第三次 “炼丹”:调整“定位”、“分类”损失权重

根据上一次训练的图考研看出,虽然大部分指标都很优秀了,但是还是需要改进(不然论文没东西写)

那么可以注意到这么几个损失函数给出的细节:

  • box_loss:边界框损失,是为了计算让预测框和真实框尽量重合
    • 通常用GIoU/DIoU/CIoU,可以看出这个图的曲线下降得没有cls_loss和dfl_loss缓和,说明预测框【定位】不够好
  • dfl_loss(DFL):把框的边或关键点当成分布来回归,说白了就是跟box_loss搭配,一起让边界更精准,也是跟【定位】有关的
  • angle_loss:OBB旋转框特有的 “旋转角度损失”,预测框旋转的角度不准也会当成损失计算,也算【定位】,图中它的下降也尤其陡峭
    • 但是留意:OBB 的 angle 通常是包含在 box 的定位损失里一起优化的(不是单独一个 angle 权重给你调),调高box_loss权重就是顺带调了angle_loss
  • cls_loss:【分类】的损失,判定这个目标物体的类别是否正确的损失,在结果图表中效果是最好的,因为下降的非常缓和

做法:

  • 因此我们应该在train.py里提高关于【定位】的【box_loss】和【dfl_loss】的权重大小,不然模型会更偏向【cls_loss】优先把分类判别做好,而稍微忽略定位(但是也不需要把【cls_loss】降低,只需要把【box_loss】和【dfl_loss】的权重提高即可)
  • 【代码如下】自己照着添加这几个参数到train.py就行了

【结果】

  • 定位相关:val/box_loss 降得更好,mAP50-95(B) 有提升,说明「提高 box/DFL、不降 cls」的方向是对的,定位确实在变好。
  • 分类:val/cls_loss 继续下降,分类没有被牺牲。
  • 注意:图表看不出就重点去result.csv对比看
    • 然后不要看损失值来对比,因为我们调高了定位的损失权重,损失值自然也是乘上了权重的,所以损失值肯定会变高,但这并不代表我们定位没有生效
    • 关键是看【mAP50】尤其是【mAP50~95】,因为这是【基于iou>0.5~0.95的对所有类别AP的平均值计算(也是对PR值的综合评估)】
    • 那么我们也知道iou越大说明预测框和实际框越贴近,所以【mAP50~95】最后在高置信度区间值越大,说明预测框定位越好

4、第四次 “炼丹”:修改yaml网络模型结构

1)分析要改进的下一步

        经过chatGPT的建议,我现在各个类别已经“顶满”,只有少数类别的识别还不是很好,尤其是angle角度定位,从而可能影响了整体的平均值。        

那么mAP50~95如果还想再顶一点,调损失权重已经没啥用了

2)修改yaml网络模型结构

        那么我们尝试一下修改网络模型结构,这一篇的原理我在往期文章:https://blog.csdn.net/m0_73991249/article/details/157207379?spm=1001.2014.3001.5501讲过,有需要自行阅读。

        那么我们这里依旧是针对【遥感影像大尺寸图片】中【捕捉微小目标】的原则,【抓小目标特指】就是要增加【更大像素尺寸】输出的检测头,也就是在原本只有P3、P4、P5检测头的yolo模型上,再加一层P2(P2来源于backbone的第2个CBS下采样,也就是从0开始数的第1层)

        注意:P2层尺寸计算,第二个下采样,也就是尺寸÷2^2,若输入640×640的图,P2层输出的就是160×160;若输入1280×1280的图,P2层输出的就是320×320;图片尺寸越大能捕捉到的特指越清晰。

        去ultralytics目录把yaml配置源码复制到我们的目录,然后把yolo11-obb.yaml复制一份改个名字,然后在这个文件里修改,不然你改官方源码出错了退不回原来的样子你就完蛋了。

代码如下,可以直接复制粘贴,我的操作就是:

在第16层后补齐了一组【上采样+concat+C3k2】和对称的一组【下采样+concat+C3k2】

并且在【P2】和【P3】的【C3k2】把 “执行2次” 添加成 “执行3次”,能更加细致融合特征

# Parameters
nc: 20 # 我的遥感影像20类
scales:
  # [depth, width, max_channels]
  n: [0.50, 0.25, 1024] # summary: 196 layers, 2695747 parameters, 2695731 gradients, 6.9 GFLOPs
  s: [0.50, 0.50, 1024] # summary: 196 layers, 9744931 parameters, 9744915 gradients, 22.7 GFLOPs
  m: [0.50, 1.00, 512] # summary: 246 layers, 20963523 parameters, 20963507 gradients, 72.2 GFLOPs
  l: [1.00, 1.00, 512] # summary: 372 layers, 26220995 parameters, 26220979 gradients, 91.3 GFLOPs
  x: [1.00, 1.50, 512] # summary: 372 layers, 58875331 parameters, 58875315 gradients, 204.3 GFLOPs

# YOLO11n backbone
backbone:
  # [from, repeats, module, args]
  - [-1, 1, Conv, [64, 3, 2]] # 0-P1/2                             # 0
  - [-1, 1, Conv, [128, 3, 2]] # 1-P2/4                            # 1
  - [-1, 2, C3k2, [256, False, 0.25]]                              # 2
  - [-1, 1, Conv, [256, 3, 2]] # 3-P3/8                            # 3
  - [-1, 2, C3k2, [512, False, 0.25]]                              # 4
  - [-1, 1, Conv, [512, 3, 2]] # 5-P4/16                           # 5
  - [-1, 2, C3k2, [512, True]]                                     # 6
  - [-1, 1, Conv, [1024, 3, 2]] # 7-P5/32                          # 7
  - [-1, 2, C3k2, [1024, True]]                                    # 8
  - [-1, 1, SPPF, [1024, 5]] # 9                                   # 9
  - [-1, 2, C2PSA, [1024]] # 10                                    # 10

# YOLO11n head
head:
  - [-1, 1, nn.Upsample, [None, 2, "nearest"]]                     # 11
  - [[-1, 6], 1, Concat, [1]] # cat backbone P4                    # 12
  - [-1, 2, C3k2, [512, False]]                                    # 13

  - [-1, 1, nn.Upsample, [None, 2, "nearest"]]                     # 14
  - [[-1, 4], 1, Concat, [1]] # cat backbone P3                    # 15
  - [-1, 3, C3k2, [256, False]] # 16 (P3/8-small)                  # 16

  - [-1, 1, nn.Upsample, [None, 2, "nearest"]]                      # 17
  - [[-1, 2], 1, Concat, [1]] # cat backbone P2 (layer 2 = C3k2 输出) # 18
  - [-1, 3, C3k2, [256, False]] # 19 (P2/4-small)                  # 19

  - [-1, 1, Conv, [256, 3, 2]]                                     # 20
  - [[-1, 16], 1, Concat, [1]] # cat head P3                       # 21
  - [-1, 2, C3k2, [512, False]] # 22 (P3/8-medium)                 # 22

  - [-1, 1, Conv, [256, 3, 2]]                                     # 23
  - [[-1, 13], 1, Concat, [1]] # cat head P4                       # 24
  - [-1, 2, C3k2, [512, False]] # 25 (P4/16-medium)                # 25

  - [-1, 1, Conv, [512, 3, 2]]                                     # 26
  - [[-1, 10], 1, Concat, [1]] # cat head P5                       # 27
  - [-1, 2, C3k2, [1024, True]] # 28 (P5/32-large)                  # 28

  - [[19, 22, 25, 28], 1, OBB, [nc, 1]] # Detect(P2, P3, P4, P5)   # 29

不理解的对照这两个结构图,第一个是没改之前,第二个是改动之后

5、第五次 “炼丹”:加 “空间注意力”

那么关于【注意力机制】这块,我写了一篇全网独一无二,整个互联网找不出第二篇更加细致、实用的文章,大部分细节都在那,这里就不说过多:https://blog.csdn.net/m0_73991249/article/details/157064811?sharetype=blogdetail&sharerId=157064811&sharerefer=PC&sharesource=m0_73991249&spm=1011.2480.3001.8118https://blog.csdn.net/m0_73991249/article/details/157064811?sharetype=blogdetail&sharerId=157064811&sharerefer=PC&sharesource=m0_73991249&spm=1011.2480.3001.8118

我就简单说其中一种注意力机制即可:

 【CBAM模块】

        因为YOLO官方有相关代码,只不过没有引入,所以我们可以用官方的代码引入、也可以自己写,两种方式使用!

  • 1)导入【CBAM】模块
  • 去到你的ultralytics安装路径下:
    • windows本地路径:
      • 去你pycharm下面点开 “外部库 / site-packages / ultralytics / nn / modules”,
      • 或者在我的电脑的 “你的conda安装目录 / envs / 你当前虚拟环境目录 / Lib / site-packages / ultralytics / nn / modules
    • 如果用autodl服务器的linux系统下路径:“ / root / miniconda3 / envs / 你的虚拟环境目录 / lib / pythonx.x / site-packages / ultralytics / nn / modules
    • 查找【conv.py】有无该CBAM模块
      • 找到ultralytics的 nn / models目录后,可以看到一个【conv.py】文件,这个文件就是定义了各个模块,我们可以【Ctrl + F】查找一下有没有CBAM这个模块
    • 如果有CBAM模块,那么我们继续在ultralytics的 nn目录下找到【task.py】文件
      • 然后在代码前面 from ultralytics.nn.modules import (......)里面加上【CBAM】,这样task.py才可以用到ultralytics的 nn / models / conv.py的CBAM模块
      • 然后还是在task.py,【Ctrl + F】查找一下 “elif m is”就能找到下图这么一块地方
        • 然后插入一段代码,一定要补上不然会报错!!!:
          elif m is CBLinear:
              c2 = args[0]
              c1 = ch[f]
              args = [c1, c2, *args[1:]]
  • 2)yaml插入CBAM注意力模块
    • 【前提,我们已经多加了P2检测层】
    • 不加P2检测层的话就

效果:

  • 夯爆了:mAP比上一次又高得多!!!另外看损失指标也降低了很多(看图表纵坐标数据,别只看曲线)

6、第六次 “炼丹”:GWD / KLD 把 OBB 看成二维高斯分布(未实现)

        最后,他开始给我上强度(为了让论文里加上的点睛之笔):加 GWD 或 KLD 作为 Rotated box regression loss(替代/融合原本 box loss),这到底是啥玩意?

1)概念

        它的核心思想其实很直观:别再把旋转框当 “4条边+角度” 去硬回归,而是把它当成一个 “二维分布” 去对齐。这样梯度更平滑、角度不连续问题更小,定位更稳(尤其像 “桥梁”、“高架桥、立交桥” 这种细长目标)

注意一个误区:

2)GWD/KLD怎么解决我们的预测框定位损失难题?

3)GWD 和 KLD选哪个

  • 【GWD】:
  • 【KLD】:

【总结】

  • 所以我的做法:第一次做,优先选 GWD。原因用人话说就是:
    • GWD 更“几何直觉”
      • 对角度边界不敏感
      • 对细长桥/立交更友好
      • 更不容易训练炸
    • KLD 也不是不好,但:
      • 它“方向性”更强
      • 解释起来要多一句
      • 实现和调参稍微更容易出歧义

4)代码实现

sorry因为中间有点麻烦。。。我懒没有实现。。。。

至此。。。已经没什么好说得了,我已经想尽办法了,对于一个小白本科生得垃圾毕设做到这个程度我觉得差不多了,再加个系统就完美了

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐