{"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"},"language_info":{"name":"python","version":"3.7.6","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# 🎓 深度学习实战教学：基于 PyTorch & EfficientDet 的目标检测流水线（完整端到端训练与推理）\n\n欢迎来到目标检测（Object Detection）实战教学课程！\n\n本教程以经典竞赛 **Global Wheat Detection（全球小麦检测）** 为案例，带你一步一步构建工业级的 SOTA（State-Of-The-Art）检测模型训练与测试集预测全流程。\n\n---\n\n### 📚 学习目标与教学路线\n1. **环境准备与依赖配置**：了解离线包引入与 EfficientDet 外部模块加载。\n2. **可复现性（Reproducibility）保障**：掌握多随机种子的统一定义与底层 CuDNN 设置。\n3. **目标检测标注数据预处理**：从原始 CSV 中提取并转换 Bounding Box（边界框）格式。\n4. **针对检测任务的分层 K 折交叉验证（Stratified K-Fold）**：解决样本分布不均与多目标分布偏差。\n5. **高级数据增强（Augmentation & CutMix）**：\n   - Albumentations 图像与边界框同步变换。\n   - 目标检测领域的 4 图拼接增强（CutMix / Mosaic 核心算法实现与原理解析）。\n6. **PyTorch 自定义 Dataset 与 DataLoader**：\n   - 为什么目标检测需要自定义 `collate_fn`？\n   - 边界框坐标系转换（VOC 格式与 EfficientDet yxyx 格式）。\n7. **EfficientDet 模型架构与迁移学习**：\n   - 加载预训练主干权重。\n   - 替换类别预测头（HeadNet）以适应单类别检测任务。\n   - 认识 `DetBenchTrain`（集成了损失函数计算的训练包装器）。\n8. **工业级训练引擎封装（Fitter）**：\n   - 梯度更新、学习率动态调度（ReduceLROnPlateau）。\n   - 验证集评估、历史最优权重自动保留与日志记录。\n9. **测试集推理与坐标逆变换（DetBenchPredict）**：\n   - 评估模式构建与 NMS 处理。\n   - 坐标还原与可视化验证。","metadata":{}},{"cell_type":"markdown","source":"## 1. 环境依赖与模块导入\n\n> **教学提示**：在比赛或受限离线环境下，通常预先下载好 `.whl` 格式的离线安装包（Wheel）。本段代码展示了如何在无法直接联网时安装 `timm` 与 `pycocotools`[cite: 1]。","metadata":{}},{"cell_type":"code","source":"# 安装 timm (PyTorch 经典图像模型库) 与 pycocotools (COCO 数据集格式评估指标工具)\n# --no-deps 参数表示不自动安装依赖项，避免破坏离线环境已有包版本\n!pip install --no-deps '../input/timm-package/timm-0.1.26-py3-none-any.whl' > /dev/null\n!pip install --no-deps '../input/pycocotools/pycocotools-2.0-cp37-cp37m-linux_x86_64.whl' > /dev/null","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T09:18:47.579146Z","iopub.execute_input":"2026-09-15T09:18:47.579521Z","iopub.status.idle":"2026-09-15T09:19:56.137940Z","shell.execute_reply.started":"2026-09-15T09:18:47.579489Z","shell.execute_reply":"2026-09-15T09:19:56.136810Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import sys\nimport os\nfrom datetime import datetime\nimport time\nimport random\nfrom glob import glob\n\n# 将外部第三方实现的开源库路径加入 Python 系统模块搜索路径 sys.path 中\nsys.path.insert(0, \"../input/timm-efficientdet-pytorch\")\nsys.path.insert(0, \"../input/omegaconf\")\n\n# 核心科学计算与深度学习框架\nimport torch\nfrom torch.utils.data import Dataset, DataLoader\nfrom torch.utils.data.sampler import SequentialSampler, RandomSampler\nimport cv2\nimport pandas as pd\nimport numpy as np\nimport matplotlib.pyplot as plt\nfrom sklearn.model_selection import StratifiedKFold\n\n# 图像增强库\nimport albumentations as A\nfrom albumentations.pytorch.transforms import ToTensorV2\n\n# --------------------------------------------------------------------------\n# 设定随机数种子，确保实验可复现（Reproducibility）\n# --------------------------------------------------------------------------\nSEED = 42\n\ndef seed_everything(seed: int):\n    \"\"\"\n    固定 Python 原生、NumPy 及 PyTorch 中的所有随机种子，\n    以保证实验的各项指标可以被严格复现。\n    \"\"\"\n    random.seed(seed)\n    os.environ['PYTHONHASHSEED'] = str(seed)\n    np.random.seed(seed)\n    torch.manual_seed(seed)\n    torch.cuda.manual_seed(seed)\n    torch.cuda.manual_seed_all(seed)  # 如果有多个 GPU\n    \n    # 确保底层卷积算法选择确定性的实现\n    torch.backends.cudnn.deterministic = True\n    # benchmark 设置为 True 时会预先寻找最适合当前硬件的卷积算子，\n    # 如果输入图片尺寸完全固定（如 512x512），设为 True 能显著加速训练。\n    torch.backends.cudnn.benchmark = True\n\nseed_everything(SEED)\nprint(f\"[INFO] 随机种子已固定为: {SEED}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T09:19:56.140720Z","iopub.execute_input":"2026-09-15T09:19:56.141092Z","iopub.status.idle":"2026-09-15T09:19:56.152547Z","shell.execute_reply.started":"2026-09-15T09:19:56.141051Z","shell.execute_reply":"2026-09-15T09:19:56.151762Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 2. 标注数据读取与边界框（Bounding Box）解析\n\n目标检测任务与普通图像分类不同：\n* 分类任务的标签只是一个类别编号。\n* 目标检测任务的标签是**包含物体位置的矩形框**（Bounding Box, 简称 bbox）。\n\n在原始 CSV 中，`bbox` 列通常保存为字符串格式 `\"[x, y, w, h]\"`[cite: 1]：\n- `x`, `y`：边界框左上角像素横纵坐标[cite: 1]。\n- `w`, `h`：边界框的宽度与高度[cite: 1]。\n\n我们需要将其解析为独立的数值列，便于后续向量化计算与几何变换[cite: 1]。","metadata":{}},{"cell_type":"code","source":"# 读取标注信息 CSV 文件\nmarking = pd.read_csv('../input/global-wheat-detection/train.csv')\n\n# 演示数据样例\nprint(\"原始数据前 3 行：\")\nprint(marking.head(3))\n\n# 解析边界框：\n# 1. 截取字符串去除两端的括号 '[' 和 ']' -> x[1:-1]\n# 2. 使用逗号作为分隔符将字符串转换为浮点数数组 np.fromstring(..., sep=',')\n# 3. 将所有框堆叠成二维矩阵 (N, 4)\nbboxs = np.stack(marking['bbox'].apply(lambda x: np.fromstring(x[1:-1], sep=',')))\n\n# 拆分为显式的 4 个列：x (左上角 x), y (左上角 y), w (框宽), h (框高)\nfor i, column in enumerate(['x', 'y', 'w', 'h']):\n    marking[column] = bboxs[:, i]\n\n# 移除旧的字符串列，释放内存\nmarking.drop(columns=['bbox'], inplace=True)\n\nprint(\"\\n解析边界框后的数据：\")\nprint(marking[['image_id', 'x', 'y', 'w', 'h', 'source']].head(3))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T09:19:56.153880Z","iopub.execute_input":"2026-09-15T09:19:56.154213Z","iopub.status.idle":"2026-09-15T09:19:56.799469Z","shell.execute_reply.started":"2026-09-15T09:19:56.154169Z","shell.execute_reply":"2026-09-15T09:19:56.798597Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 3. 分层 K 折交叉验证划分（Stratified K-Fold）\n\n> **思考问题**：为什么目标检测数据集不能直接进行简单的随机划分（Random Split）？\n>\n> 1. **数据源差异（Domain Shift）**：本数据集中图像来自不同国家、土壤和光照环境（记录在 `source` 列中）[cite: 1]。\n> 2. **目标密集度差异**：有的图片只有几个麦穗，有的图片包含上百个麦穗。\n>\n> **教学方案**：将 `数据源类别` 与 `麦穗数量密集区间`（每 15 个一组）组合成一个综合类别特征 `stratify_group`，并以此做分层划分，确保训练集与验证集具有一致的数据分布[cite: 1]。","metadata":{}},{"cell_type":"code","source":"# 实例化 5 折分层交叉验证分割器\nskf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)\n\n# 构建以单张图片（image_id）为单位的元数据统计表\ndf_folds = marking[['image_id']].copy()\ndf_folds.loc[:, 'bbox_count'] = 1\ndf_folds = df_folds.groupby('image_id').count()  # 计算每张图片包含的边界框总数\n\n# 获取每张图片的采集来源 source\ndf_folds.loc[:, 'source'] = marking[['image_id', 'source']].groupby('image_id').min()['source']\n\n# 构建复合分层标签：如 'ethz_1_1'、'arvalis_1_3' 等\n# x // 15 将框数量按 15 为单位离散化为等级\ndf_folds.loc[:, 'stratify_group'] = np.char.add(\n    df_folds['source'].values.astype(str),\n    df_folds['bbox_count'].apply(lambda x: f'_{x // 15}').values.astype(str)\n)\n\n# 初始化 fold 编号列\ndf_folds.loc[:, 'fold'] = 0\n\n# 执行分层划分，给每一张图片指定所属的 fold (0 ~ 4)\nfor fold_number, (train_index, val_index) in enumerate(skf.split(X=df_folds.index, y=df_folds['stratify_group'])):\n    df_folds.loc[df_folds.iloc[val_index].index, 'fold'] = fold_number\n\nprint(\"各折样本数量统计：\")\nprint(df_folds['fold'].value_counts())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T09:19:56.800886Z","iopub.execute_input":"2026-09-15T09:19:56.801249Z","iopub.status.idle":"2026-09-15T09:19:57.195896Z","shell.execute_reply.started":"2026-09-15T09:19:56.801210Z","shell.execute_reply":"2026-09-15T09:19:57.195043Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 4. 数据增强策略（Albumentations）\n\n目标检测的数据增强远比图像分类复杂，因为**对图片进行裁剪、翻转和缩放的同时，边界框坐标必须同步进行几何变换**。\n\n这里我们使用专业的 `albumentations` 库，并配置 `bbox_params`[cite: 1]：\n- 格式选用 `pascal_voc`：表示边界框以 `[x_min, y_min, x_max, y_max]` 绝对像素坐标表示[cite: 1]。\n- `min_area` 与 `min_visibility`：过滤掉在裁剪过程中面积过小或被截断殆尽的无效边界框[cite: 1]。","metadata":{}},{"cell_type":"code","source":"def get_train_transforms():\n    \"\"\"\n    训练集数据增强流水线：结合尺寸缩放、色彩抖动、几何翻转与遮挡增强\n    \"\"\"\n    return A.Compose(\n        [\n            # 随机尺寸裁剪至 1024 尺寸，模拟尺度变化\n            A.RandomSizedCrop(min_max_height=(800, 800), height=1024, width=1024, p=0.5),\n            \n            # 光学/颜色变化（二选一）：色相饱和度调整 或 亮度对比度调整\n            A.OneOf([\n                A.HueSaturationValue(hue_shift_limit=0.2, sat_shift_limit=0.2, val_shift_limit=0.2, p=0.9),\n                A.RandomBrightnessContrast(brightness_limit=0.2, contrast_limit=0.2, p=0.9),\n            ], p=0.9),\n            \n            # 极低概率转换为灰度图，增强模型对颜色特征的鲁棒性\n            A.ToGray(p=0.01),\n            \n            # 空间几何翻转\n            A.HorizontalFlip(p=0.5),\n            A.VerticalFlip(p=0.5),\n            \n            # 统一缩放到模型设定的输入尺寸 512x512\n            A.Resize(height=512, width=512, p=1.0),\n            \n            # Cutout 数据增强：随机挖掉几个黑色矩形孔洞，防止过拟合\n            # (注: 在较新版本 Albumentations 中推荐使用 A.CoarseDropout)\n            A.Cutout(num_holes=8, max_h_size=64, max_w_size=64, fill_value=0, p=0.5),\n            \n            # 转换为 PyTorch 张量 (C, H, W)\n            ToTensorV2(p=1.0),\n        ], \n        p=1.0, \n        bbox_params=A.BboxParams(\n            format='pascal_voc',   # [xmin, ymin, xmax, ymax]\n            min_area=0,            # 过滤面积小于此阈值的框\n            min_visibility=0,      # 过滤被裁剪后可见度小于此阈值的框\n            label_fields=['labels']# 绑定类别标签\n        )\n    )\n\ndef get_valid_transforms():\n    \"\"\"\n    验证集数据预处理：不做任何随机增强，仅缩放并转为张量\n    \"\"\"\n    return A.Compose(\n        [\n            A.Resize(height=512, width=512, p=1.0),\n            ToTensorV2(p=1.0),\n        ], \n        p=1.0, \n        bbox_params=A.BboxParams(\n            format='pascal_voc',\n            min_area=0, \n            min_visibility=0,\n            label_fields=['labels']\n        )\n    )","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T09:19:57.198168Z","iopub.execute_input":"2026-09-15T09:19:57.198565Z","iopub.status.idle":"2026-09-15T09:19:57.208078Z","shell.execute_reply.started":"2026-09-15T09:19:57.198536Z","shell.execute_reply":"2026-09-15T09:19:57.207250Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 5. 高级数据加载器：DatasetRetriever 与 CutMix 算法详解\n\n### 什么是 CutMix 增强（在目标检测中）？\n通常图像分类的 CutMix 只是将两张图片切片融合；而在目标检测中，这里的实现更类似于 **Mosaic（4 图拼接增强）**：\n1. 随机选取一个十字中心点 `(xc, yc)`[cite: 1]。\n2. 将当前图片与随机选择的另外 3 张图片分别填入左上、右上、左下、右下四个子区域[cite: 1]。\n3. 将每张图片对应的边界框按照子区域偏移量（`padw`, `padh`）进行平移，并剔除超出画布或重叠面积归零的无效框[cite: 1]。\n\n⚠️ **重要避坑警告（坐标格式）**：\n`albumentations` 输出的 VOC 格式是 `[xmin, ymin, xmax, ymax]`；\n但 `efficientdet-pytorch` 模型底层计算损失时要求输入的坐标格式是 **`[ymin, xmin, ymax, xmax]`（即 yxyx 格式）**[cite: 1]！\n因此在数据传给模型前，必须执行切片转置：`target['boxes'][:, [0, 1, 2, 3]] = target['boxes'][:, [1, 0, 3, 2]]`[cite: 1]。","metadata":{}},{"cell_type":"code","source":"TRAIN_ROOT_PATH = '../input/global-wheat-detection/train'\n\nclass DatasetRetriever(Dataset):\n    \"\"\"\n    PyTorch 自定义数据集：支持单图加载与 4 图 CutMix 增强加载\n    \"\"\"\n    def __init__(self, marking: pd.DataFrame, image_ids: np.ndarray, transforms=None, test=False):\n        super().__init__()\n        self.image_ids = image_ids\n        self.marking = marking\n        self.transforms = transforms\n        self.test = test  # 若为 True（测试/验证阶段），不使用 CutMix\n\n    def __getitem__(self, index: int):\n        image_id = self.image_ids[index]\n        \n        # 50% 概率触发常规加载，50% 概率触发 4 图拼接 CutMix 增强\n        if self.test or random.random() > 0.5:\n            image, boxes = self.load_image_and_boxes(index)\n        else:\n            image, boxes = self.load_cutmix_image_and_boxes(index)\n\n        # 本任务属于单类别检测（麦穗检测），所有框的类别标签固定为 1（0 通常保留给背景）\n        labels = torch.ones((boxes.shape[0],), dtype=torch.int64)\n        \n        target = {\n            'boxes': boxes,\n            'labels': labels,\n            'image_id': torch.tensor([index])\n        }\n\n        # 执行 Albumentations 数据增强\n        if self.transforms:\n            # 尝试最多 10 次增强，防止极其极端的数据裁剪导致所有 bbox 被全部裁掉（空框）\n            for i in range(10):\n                sample = self.transforms(**{\n                    'image': image,\n                    'bboxes': target['boxes'],\n                    'labels': labels\n                })\n                if len(sample['bboxes']) > 0:\n                    image = sample['image']\n                    # 将 boxes 列表转换为 Tensor，形状为 (N, 4)\n                    target['boxes'] = torch.stack(tuple(map(torch.tensor, zip(*sample['bboxes'])))).permute(1, 0)\n                    \n                    # ---------------------------------------------------------\n                    # 核心步骤：坐标格式转换 [xmin, ymin, xmax, ymax] -> [ymin, xmin, ymax, xmax]\n                    # EfficientDet 官方实现强制要求 yxyx 排布格式\n                    # ---------------------------------------------------------\n                    target['boxes'][:, [0, 1, 2, 3]] = target['boxes'][:, [1, 0, 3, 2]]\n                    break\n\n        return image, target, image_id\n\n    def __len__(self) -> int:\n        return self.image_ids.shape[0]\n\n    def load_image_and_boxes(self, index: int):\n        \"\"\"\n        单图常规读取函数：读取图片，并将 [x, y, w, h] 转换为 [xmin, ymin, xmax, ymax]\n        \"\"\"\n        image_id = self.image_ids[index]\n        image = cv2.imread(f'{TRAIN_ROOT_PATH}/{image_id}.jpg', cv2.IMREAD_COLOR)\n        # OpenCV 默认读取格式为 BGR，需要转为标准的 RGB\n        image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB).astype(np.float32)\n        image /= 255.0  # 归一化到 [0, 1]\n        \n        # 提取当前图片对应的所有边界框\n        records = self.marking[self.marking['image_id'] == image_id]\n        boxes = records[['x', 'y', 'w', 'h']].values\n        \n        # 转换为 pascal_voc 格式：[xmin, ymin, xmax, ymax]\n        boxes[:, 2] = boxes[:, 0] + boxes[:, 2]  # xmax = xmin + width\n        boxes[:, 3] = boxes[:, 1] + boxes[:, 3]  # ymax = ymin + height\n        return image, boxes\n\n    def load_cutmix_image_and_boxes(self, index: int, imsize: int = 1024):\n        \"\"\"\n        4 图拼接 CutMix 核心实现\n        原理：在画布上生成随机中心点 (xc, yc)，将 4 张不同的图片与边界框拼合进同一个大画布中。\n        \"\"\"\n        w, h = imsize, imsize\n        s = imsize // 2\n    \n        # 随机选取拼接的中心分割点 (xc, yc)\n        xc, yc = [int(random.uniform(imsize * 0.25, imsize * 0.75)) for _ in range(2)]\n        \n        # 随机选择另外 3 张图片的索引进行混合\n        indexes = [index] + [random.randint(0, self.image_ids.shape[0] - 1) for _ in range(3)]\n\n        # 初始化拼接画板 (1024, 1024, 3)\n        result_image = np.full((imsize, imsize, 3), 1.0, dtype=np.float32)\n        result_boxes = []\n\n        for i, idx in enumerate(indexes):\n            image, boxes = self.load_image_and_boxes(idx)\n            \n            # 根据象限计算裁剪区域与填入区域的坐标映射\n            if i == 0:  # 左上角子图\n                x1a, y1a, x2a, y2a = max(xc - w, 0), max(yc - h, 0), xc, yc\n                x1b, y1b, x2b, y2b = w - (x2a - x1a), h - (y2a - y1a), w, h\n            elif i == 1:  # 右上角子图\n                x1a, y1a, x2a, y2a = xc, max(yc - h, 0), min(xc + w, s * 2), yc\n                x1b, y1b, x2b, y2b = 0, h - (y2a - y1a), min(w, x2a - x1a), h\n            elif i == 2:  # 左下角子图\n                x1a, y1a, x2a, y2a = max(xc - w, 0), yc, xc, min(s * 2, yc + h)\n                x1b, y1b, x2b, y2b = w - (x2a - x1a), 0, max(xc, w), min(y2a - y1a, h)\n            elif i == 3:  # 右下角子图\n                x1a, y1a, x2a, y2a = xc, yc, min(xc + w, s * 2), min(s * 2, yc + h)\n                x1b, y1b, x2b, y2b = 0, 0, min(w, x2a - x1a), min(y2a - y1a, h)\n            \n            # 复制像素到合成大图\n            result_image[y1a:y2a, x1a:x2a] = image[y1b:y2b, x1b:x2b]\n            \n            # 边界框坐标根据平移量进行调整\n            padw = x1a - x1b\n            padh = y1a - y1b\n            boxes[:, 0] += padw\n            boxes[:, 1] += padh\n            boxes[:, 2] += padw\n            boxes[:, 3] += padh\n\n            result_boxes.append(boxes)\n\n        # 合并所有子图的边界框\n        result_boxes = np.concatenate(result_boxes, 0)\n        # 限制坐标范围在画布内 [0, 1024]\n        np.clip(result_boxes[:, 0:], 0, 2 * s, out=result_boxes[:, 0:])\n        result_boxes = result_boxes.astype(np.int32)\n        \n        # 过滤掉由于裁剪导致宽或高变为 0 的无效框\n        valid_mask = (result_boxes[:, 2] - result_boxes[:, 0]) * (result_boxes[:, 3] - result_boxes[:, 1]) > 0\n        result_boxes = result_boxes[np.where(valid_mask)]\n        return result_image, result_boxes","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T09:19:57.210056Z","iopub.execute_input":"2026-09-15T09:19:57.210399Z","iopub.status.idle":"2026-09-15T09:19:57.239775Z","shell.execute_reply.started":"2026-09-15T09:19:57.210367Z","shell.execute_reply":"2026-09-15T09:19:57.239024Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 6. 数据加载与可视化验证\n\n> **教学铁律**：在将数据送入神经网络训练之前，**务必先将数据可视化出来**！\n> 这可以确保图像色彩通道（RGB vs BGR）、归一化状态、边界框相对位置以及坐标转换没有任何偏差。","metadata":{}},{"cell_type":"code","source":"# 使用第 0 折作为验证集，其余折作为训练集\nfold_number = 0\n\ntrain_dataset = DatasetRetriever(\n    image_ids=df_folds[df_folds['fold'] != fold_number].index.values,\n    marking=marking,\n    transforms=get_train_transforms(),\n    test=False,\n)\n\nvalidation_dataset = DatasetRetriever(\n    image_ids=df_folds[df_folds['fold'] == fold_number].index.values,\n    marking=marking,\n    transforms=get_valid_transforms(),\n    test=True,\n)\n\nprint(f\"训练集样本数量: {len(train_dataset)}\")\nprint(f\"验证集样本数量: {len(validation_dataset)}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T09:19:57.241084Z","iopub.execute_input":"2026-09-15T09:19:57.241412Z","iopub.status.idle":"2026-09-15T09:19:57.256908Z","shell.execute_reply.started":"2026-09-15T09:19:57.241380Z","shell.execute_reply":"2026-09-15T09:19:57.256008Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# 取出训练集中的一个样本进行可视化检查\nimage, target, image_id = train_dataset[1]\n\n# 注意：因为前面经过了 yxyx 转换，此时 boxes 的列为: [ymin, xmin, ymax, xmax]\nboxes = target['boxes'].cpu().numpy().astype(np.int32)\n\n# PyTorch 张量形状为 (C, H, W)，显示图像需要转换回 OpenCV / Matplotlib 的 (H, W, C)\nnumpy_image = image.permute(1, 2, 0).cpu().numpy().copy()\n\nfig, ax = plt.subplots(1, 1, figsize=(12, 12))\n\n# 遍历并在图片上绘制绿色边界框\nfor box in boxes:\n    # box: [ymin, xmin, ymax, xmax]\n    # cv2.rectangle 需要的坐标顺序是: (pt1_x, pt1_y), (pt2_x, pt2_y)\n    cv2.rectangle(\n        numpy_image,\n        (box[1], box[0]),  # (xmin, ymin)\n        (box[3], box[2]),  # (xmax, ymax)\n        (0, 1, 0),         # 绿色 (RGB 空间)\n        2\n    )\n    \nax.set_axis_off()\nax.set_title(f\"Augmented Sample (ID: {image_id}) with {len(boxes)} boxes\", fontsize=14)\nax.imshow(numpy_image)\nplt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T09:19:57.258008Z","iopub.execute_input":"2026-09-15T09:19:57.258216Z","iopub.status.idle":"2026-09-15T09:19:57.657149Z","shell.execute_reply.started":"2026-09-15T09:19:57.258196Z","shell.execute_reply":"2026-09-15T09:19:57.656385Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 7. 评估器与训练引擎（AverageMeter & Fitter）\n\n- **`AverageMeter`**：用于动态平滑计算每个 Batch 的 Loss，维护当前 Epoch 的平均损失[cite: 1]。\n- **`Fitter`**：工程级训练循环封装器，统一负责：\n  1. 训练/验证阶段切换（`model.train()` 与 `model.eval()`）[cite: 1]。\n  2. 梯度反向传播与优化器步进[cite: 1]。\n  3. 学习率衰减（Scheduler）监控与调整[cite: 1]。\n  4. 检查点自动保存（保存最新的权重 `last-checkpoint.bin` 和前 3 个最优模型权重）[cite: 1]。","metadata":{}},{"cell_type":"code","source":"class AverageMeter(object):\n    \"\"\"\n    跟踪指标统计工具类：记录瞬时值、累加值与平均值\n    \"\"\"\n    def __init__(self):\n        self.reset()\n\n    def reset(self):\n        self.val = 0.0\n        self.avg = 0.0\n        self.sum = 0.0\n        self.count = 0\n\n    def update(self, val: float, n: int = 1):\n        self.val = val\n        self.sum += val * n\n        self.count += n\n        self.avg = self.sum / self.count","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T09:19:57.658394Z","iopub.execute_input":"2026-09-15T09:19:57.658708Z","iopub.status.idle":"2026-09-15T09:19:57.666556Z","shell.execute_reply.started":"2026-09-15T09:19:57.658677Z","shell.execute_reply":"2026-09-15T09:19:57.665691Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import warnings\nwarnings.filterwarnings(\"ignore\")\n\nclass Fitter:\n    \"\"\"\n    标准训练与评估引擎封装器\n    \"\"\"\n    def __init__(self, model, device, config):\n        self.config = config\n        self.epoch = 0\n\n        # 模型保存目录与日志文件路径\n        self.base_dir = f'./{config.folder}'\n        if not os.path.exists(self.base_dir):\n            os.makedirs(self.base_dir)\n        \n        self.log_path = f'{self.base_dir}/log.txt'\n        self.best_summary_loss = 10**5  # 初始化为一个极大的损失值\n\n        self.model = model\n        self.device = device\n\n        # 权重衰减（Weight Decay）分组策略：不对 bias 和 LayerNorm 应用权重衰减\n        param_optimizer = list(self.model.named_parameters())\n        no_decay = ['bias', 'LayerNorm.bias', 'LayerNorm.weight']\n        optimizer_grouped_parameters = [\n            {'params': [p for n, p in param_optimizer if not any(nd in n for nd in no_decay)], 'weight_decay': 0.001},\n            {'params': [p for n, p in param_optimizer if any(nd in n for nd in no_decay)], 'weight_decay': 0.0}\n        ]\n\n        # 使用分组参数配置 AdamW 优化器\n        self.optimizer = torch.optim.AdamW(optimizer_grouped_parameters, lr=config.lr)\n        \n        # 学习率调度器（Learning Rate Scheduler）\n        self.scheduler = config.SchedulerClass(self.optimizer, **config.scheduler_params)\n        self.log(f'Fitter prepared. Device is {self.device}')\n\n    def fit(self, train_loader, validation_loader):\n        \"\"\"\n        执行多轮 Epoch 训练与验证\n        \"\"\"\n        for e in range(self.config.n_epochs):\n            if self.config.verbose:\n                lr = self.optimizer.param_groups[0]['lr']\n                timestamp = datetime.utcnow().isoformat()\n                self.log(f'\\n{timestamp} | Epoch {self.epoch} | LR: {lr:.8f}')\n\n            # 1. 训练单个 Epoch\n            t_train = time.time()\n            summary_loss = self.train_one_epoch(train_loader)\n            self.log(f'[RESULT]: Train. Epoch: {self.epoch}, summary_loss: {summary_loss.avg:.5f}, time: {(time.time() - t_train):.2f}s')\n            self.save(f'{self.base_dir}/last-checkpoint.bin')\n\n            # 2. 验证阶段\n            t_val = time.time()\n            val_summary_loss = self.validation(validation_loader)\n            self.log(f'[RESULT]: Val. Epoch: {self.epoch}, summary_loss: {val_summary_loss.avg:.5f}, time: {(time.time() - t_val):.2f}s')\n\n            # 3. 若当前模型优于历史最佳，则保存 Checkpoint\n            if val_summary_loss.avg < self.best_summary_loss:\n                self.best_summary_loss = val_summary_loss.avg\n                self.model.eval()\n                self.save(f'{self.base_dir}/best-checkpoint-{str(self.epoch).zfill(3)}epoch.bin')\n                # 仅保留最近的 3 个最优模型，删除过旧的检查点以节省磁盘空间\n                for path in sorted(glob(f'{self.base_dir}/best-checkpoint-*epoch.bin'))[:-3]:\n                    os.remove(path)\n\n            # 4. 根据验证损失调度学习率\n            if self.config.validation_scheduler:\n                self.scheduler.step(metrics=val_summary_loss.avg)\n\n            self.epoch += 1\n\n    def train_one_epoch(self, train_loader):\n        \"\"\"\n        单轮训练流程\n        \"\"\"\n        self.model.train()\n        summary_loss = AverageMeter()\n        t = time.time()\n\n        for step, (images, targets, image_ids) in enumerate(train_loader):\n            if self.config.verbose and step % self.config.verbose_step == 0:\n                print(\n                    f'Train Step {step}/{len(train_loader)}, ' + \\\n                    f'summary_loss: {summary_loss.avg:.5f}, ' + \\\n                    f'time: {(time.time() - t):.2f}s', end='\\r'\n                )\n            \n            # 将图像列表打包成统一的批次张量 (Batch_size, C, H, W)\n            images = torch.stack(images)\n            images = images.to(self.device).float()\n            batch_size = images.shape[0]\n\n            # 目标检测中每个样本包含的边界框数量不同，因此保持为列表形式传输到 GPU\n            boxes = [target['boxes'].to(self.device).float() for target in targets]\n            labels = [target['labels'].to(self.device).float() for target in targets]\n\n            self.optimizer.zero_grad()\n            \n            # DetBenchTrain 前向计算输出包括: (总损失 loss, 分类损失 class_loss, 回归损失 box_loss)\n            loss, _, _ = self.model(images, boxes, labels)\n            \n            loss.backward()  # 反向传播计算梯度\n            summary_loss.update(loss.detach().item(), batch_size)\n            self.optimizer.step()  # 参数更新\n\n            # 步级学习率衰减（如 OneCycleLR）\n            if self.config.step_scheduler:\n                self.scheduler.step()\n\n        return summary_loss\n\n    def validation(self, val_loader):\n        \"\"\"\n        验证评估流程\n        \"\"\"\n        self.model.eval()\n        summary_loss = AverageMeter()\n        t = time.time()\n\n        for step, (images, targets, image_ids) in enumerate(val_loader):\n            if self.config.verbose and step % self.config.verbose_step == 0:\n                print(\n                    f'Val Step {step}/{len(val_loader)}, ' + \\\n                    f'summary_loss: {summary_loss.avg:.5f}, ' + \\\n                    f'time: {(time.time() - t):.2f}s', end='\\r'\n                )\n            \n            # 验证阶段关闭梯度追踪，大幅节约显存和算力\n            with torch.no_grad():\n                images = torch.stack(images)\n                batch_size = images.shape[0]\n                images = images.to(self.device).float()\n                boxes = [target['boxes'].to(self.device).float() for target in targets]\n                labels = [target['labels'].to(self.device).float() for target in targets]\n\n                loss, _, _ = self.model(images, boxes, labels)\n                summary_loss.update(loss.detach().item(), batch_size)\n\n        return summary_loss\n    \n    def save(self, path: str):\n        \"\"\"保存模型权重及训练状态\"\"\"\n        self.model.eval()\n        torch.save({\n            'model_state_dict': self.model.model.state_dict(),\n            'optimizer_state_dict': self.optimizer.state_dict(),\n            'scheduler_state_dict': self.scheduler.state_dict(),\n            'best_summary_loss': self.best_summary_loss,\n            'epoch': self.epoch,\n        }, path)\n\n    def load(self, path: str):\n        \"\"\"从断点加载训练状态\"\"\"\n        checkpoint = torch.load(path)\n        self.model.model.load_state_dict(checkpoint['model_state_dict'])\n        self.optimizer.load_state_dict(checkpoint['optimizer_state_dict'])\n        self.scheduler.load_state_dict(checkpoint['scheduler_state_dict'])\n        self.best_summary_loss = checkpoint['best_summary_loss']\n        self.epoch = checkpoint['epoch'] + 1\n        \n    def log(self, message: str):\n        \"\"\"打印并追加保存日志到磁盘\"\"\"\n        if self.config.verbose:\n            print(message)\n        with open(self.log_path, 'a+') as logger:\n            logger.write(f'{message}\\n')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T09:19:57.668116Z","iopub.execute_input":"2026-09-15T09:19:57.668494Z","iopub.status.idle":"2026-09-15T09:19:57.704004Z","shell.execute_reply.started":"2026-09-15T09:19:57.668455Z","shell.execute_reply":"2026-09-15T09:19:57.703191Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 8. 超参数与全局配置（TrainGlobalConfig）\n\n在深度学习工程中，将所有可调超参数集中到一个静态类或配置文件中是一个良好的工程习惯[cite: 1]。","metadata":{}},{"cell_type":"code","source":"class TrainGlobalConfig:\n    num_workers = 2          # 数据加载子进程数\n    batch_size = 4           # 批大小（显存不足可调为 2）\n    n_epochs = 1             # 教学演示设为 3，完整训练可设为 40\n    lr = 0.0002              # 初始学习率\n\n    folder = 'effdet5-cutmix-augmix'  # 训练模型与日志保存目录\n\n    # 日志输出开关与步长\n    verbose = True\n    verbose_step = 1\n\n    # 学习率策略控制\n    step_scheduler = False        # 是否在每个 batch 之后 step\n    validation_scheduler = True   # 是否在验证集评估后 step\n\n    # 学习率调度器：ReduceLROnPlateau 当指标停止下降时动态减小学习率\n    SchedulerClass = torch.optim.lr_scheduler.ReduceLROnPlateau\n    scheduler_params = dict(\n        mode='min',         # 监控指标最小化（Loss 越小越好）\n        factor=0.5,         # 学习率衰减系数：new_lr = lr * factor\n        patience=1,         # 容忍指标连续多少个 Epoch 不下降\n        verbose=False, \n        threshold=0.0001,\n        threshold_mode='abs',\n        cooldown=0,\n        min_lr=1e-8,\n        eps=1e-08\n    )","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T09:19:57.705184Z","iopub.execute_input":"2026-09-15T09:19:57.705542Z","iopub.status.idle":"2026-09-15T09:19:57.726078Z","shell.execute_reply.started":"2026-09-15T09:19:57.705509Z","shell.execute_reply":"2026-09-15T09:19:57.725229Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 9. 构建 DataLoader 与自定义 collate_fn\n\n> **核心考点（面试高频）**：为什么目标检测必须要自定义 `collate_fn`[cite: 1]？\n>\n> - 在分类任务中，每张图片只有一个标签（标量），PyTorch 默认的 `collate_fn` 会直接用 `torch.stack` 将张量在第 0 维拼接。\n> - 在目标检测任务中，每张图片上的**物体数量是不确定的**（图片 A 有 5 个目标，图片 B 有 20 个目标）。它们的形状是 `(5, 4)` 与 `(20, 4)`，**无法直接用 Tensor 堆叠成一个规则的矩阵**！\n> - 因此，我们定义 `collate_fn = tuple(zip(*batch))`，将一个 Batch 内的数据解包成独立的元组列表返回[cite: 1]。","metadata":{}},{"cell_type":"code","source":"def collate_fn(batch):\n    \"\"\"\n    自定义批次打包函数：将 batch 内样本按照 [(img, target, id), ...] 形式转置为\n    (tuple(images), tuple(targets), tuple(image_ids))\n    \"\"\"\n    return tuple(zip(*batch))\n\ndef run_training():\n    # 检测是否有可用的 GPU 设备\n    device = torch.device('cuda:0' if torch.cuda.is_available() else 'cpu')\n    print(f\"[INFO] 当前训练设备: {device}\")\n    \n    net.to(device)\n\n    # 训练集 DataLoader（使用随机采样 RandomSampler）\n    train_loader = torch.utils.data.DataLoader(\n        train_dataset,\n        batch_size=TrainGlobalConfig.batch_size,\n        sampler=RandomSampler(train_dataset),\n        pin_memory=False,\n        drop_last=True,     # 丢弃不能被 batch_size 整除的最后一个批次\n        num_workers=TrainGlobalConfig.num_workers,\n        collate_fn=collate_fn,\n    )\n    \n    # 验证集 DataLoader（使用顺序采样 SequentialSampler）\n    val_loader = torch.utils.data.DataLoader(\n        validation_dataset, \n        batch_size=TrainGlobalConfig.batch_size,\n        num_workers=TrainGlobalConfig.num_workers,\n        shuffle=False,\n        sampler=SequentialSampler(validation_dataset),\n        pin_memory=False,\n        collate_fn=collate_fn,\n    )\n\n    # 启动训练流程\n    fitter = Fitter(model=net, device=device, config=TrainGlobalConfig)\n    fitter.fit(train_loader, val_loader)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T09:19:57.727251Z","iopub.execute_input":"2026-09-15T09:19:57.727604Z","iopub.status.idle":"2026-09-15T09:19:57.738694Z","shell.execute_reply.started":"2026-09-15T09:19:57.727570Z","shell.execute_reply":"2026-09-15T09:19:57.738010Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 10. EfficientDet 模型构建与迁移学习微调\n\nEfficientDet 是一种高精度、低算力消耗的双向特征金字塔（BiFPN）检测模型[cite: 1]。\n1. **`get_efficientdet_config('tf_efficientdet_d5')`**：加载 D5 模型架构配置参数[cite: 1]。\n2. **`HeadNet` 替换**：原始预训练权重是在 COCO 数据集上训练的（包含 90 个目标分类），本任务仅需检测 1 类（麦穗），因此必须重新初始化并替换分类头 `net.class_net`[cite: 1]。\n3. **`DetBenchTrain`**：`effdet` 库封装的训练台包装器。在模型前向传播时，会自动结合 Anchor 框计算 Focal Loss（分类）与 Smooth L1 Loss（回归）的总损失[cite: 1]。","metadata":{}},{"cell_type":"code","source":"from effdet import get_efficientdet_config, EfficientDet, DetBenchTrain\nfrom effdet.efficientdet import HeadNet\n\ndef get_net():\n    \"\"\"\n    构建 EfficientDet-D5 模型并替换预测头\n    \"\"\"\n    # 获取 D5 标准网络配置\n    config = get_efficientdet_config('tf_efficientdet_d5')\n    net = EfficientDet(config, pretrained_backbone=False)\n    \n    # 加载预训练权重 Checkpoint（在比赛离线数据集中加载）\n    checkpoint_path = '../input/efficientdet/efficientdet_d5-ef44aea8.pth'\n    if os.path.exists(checkpoint_path):\n        checkpoint = torch.load(checkpoint_path)\n        net.load_state_dict(checkpoint)\n        print(\"[INFO] 成功加载预训练权重！\")\n    else:\n        print(\"[WARNING] 未找到离线权重文件，模型将使用随机初始化权重（教学模式）。\")\n    \n    # 修改目标类别数：本任务麦穗类别数为 1\n    config.num_classes = 1\n    config.image_size = 512\n    \n    # 重新构建分类预测头\n    net.class_net = HeadNet(config, num_outputs=config.num_classes, norm_kwargs=dict(eps=.001, momentum=.01))\n    \n    # 返回具备内置损失计算功能的 DetBenchTrain 实例\n    return DetBenchTrain(net, config)\n\nnet = get_net()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T09:19:57.739777Z","iopub.execute_input":"2026-09-15T09:19:57.739982Z","iopub.status.idle":"2026-09-15T09:19:58.923872Z","shell.execute_reply.started":"2026-09-15T09:19:57.739962Z","shell.execute_reply":"2026-09-15T09:19:58.922921Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 11. 启动模型训练\n\n点击运行下方代码单元格即可正式启动训练[cite: 1]。","metadata":{}},{"cell_type":"code","source":"# 开始训练\nrun_training()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T09:19:58.925564Z","iopub.execute_input":"2026-09-15T09:19:58.925796Z","iopub.status.idle":"2026-09-15T09:30:54.187650Z","shell.execute_reply.started":"2026-09-15T09:19:58.925773Z","shell.execute_reply":"2026-09-15T09:30:54.186872Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 12. 测试集加载与前向推理（Inference Pipeline）\n\n目标检测的测试验证与分类模型有两点本质不同：\n1. **推理包装器（`DetBenchPredict`）**：训练时用 `DetBenchTrain` 计算损失[cite: 1]；推理时用 `DetBenchPredict`，它会在网络输出后自动集成 **Soft-NMS / NMS（非极大值抑制）**，剔除冗余重叠候选框。\n2. **坐标反向还原映射**：输入图像经过了 `512x512` 压缩缩放，模型输出的预测框是基于 `512` 尺寸的；我们必须将其乘以比例因子，还原回原始图像分辨率（如 `1024x1024`）。\n3. **进程安全（避免 `QueueFeederThread` 异常）**：在单纯的预测遍历阶段，将 `num_workers` 设为 `0` 可以规避管道损坏与子进程死锁问题。","metadata":{}},{"cell_type":"code","source":"TEST_ROOT_PATH = '../input/global-wheat-detection/test'\n\ndef get_test_transforms():\n    \"\"\"\n    测试集预处理：统一缩放至模型输入尺寸 512x512 并转换为 Tensor\n    \"\"\"\n    return A.Compose([\n        A.Resize(height=512, width=512, p=1.0),\n        ToTensorV2(p=1.0),\n    ])\n\nclass DatasetTestRetriever(Dataset):\n    \"\"\"\n    测试集专用 Dataset：仅读取图像并记录原始长宽尺寸\n    \"\"\"\n    def __init__(self, image_ids, transforms=None):\n        super().__init__()\n        self.image_ids = image_ids\n        self.transforms = transforms\n\n    def __getitem__(self, index: int):\n        image_id = self.image_ids[index]\n        image_path = f'{TEST_ROOT_PATH}/{image_id}.jpg'\n        \n        image = cv2.imread(image_path, cv2.IMREAD_COLOR)\n        image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB).astype(np.float32)\n        \n        orig_h, orig_w, _ = image.shape\n        image /= 255.0\n\n        if self.transforms:\n            sample = self.transforms(image=image)\n            image = sample['image']\n\n        # 返回模型输入图、原图宽高 (h, w) 以及图像 ID\n        return image, (orig_h, orig_w), image_id\n\n    def __len__(self) -> int:\n        return len(self.image_ids)\n\n# 获取测试集所有图像 ID\ntest_image_paths = glob(f'{TEST_ROOT_PATH}/*.jpg')\ntest_image_ids = [os.path.splitext(os.path.basename(p))[0] for p in test_image_paths]\n\ntest_dataset = DatasetTestRetriever(\n    image_ids=test_image_ids,\n    transforms=get_test_transforms()\n)\n\ndef test_collate_fn(batch):\n    return tuple(zip(*batch))\n\n# 单进程测试加载（num_workers=0），稳定高效\ntest_loader = DataLoader(\n    test_dataset,\n    batch_size=2,\n    shuffle=False,\n    num_workers=0,\n    pin_memory=False,\n    collate_fn=test_collate_fn\n)\n\nprint(f\"[INFO] 成功加载测试集样本数: {len(test_dataset)}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T09:30:54.189957Z","iopub.execute_input":"2026-09-15T09:30:54.190353Z","iopub.status.idle":"2026-09-15T09:30:54.204304Z","shell.execute_reply.started":"2026-09-15T09:30:54.190311Z","shell.execute_reply":"2026-09-15T09:30:54.203386Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================\n# 修复导入：使用旧版本 effdet 中的 DetBenchEval 代替 DetBenchPredict\n# ============================================================\nfrom effdet import DetBenchEval\n\ndef load_net_for_inference(checkpoint_path: str, device: torch.device):\n    \"\"\"\n    加载已训练的最佳权重，并将模型包装为用于推理阶段的 DetBenchEval\n    \"\"\"\n    config = get_efficientdet_config('tf_efficientdet_d5')\n    net = EfficientDet(config, pretrained_backbone=False)\n    \n    config.num_classes = 1\n    config.image_size = 512\n    net.class_net = HeadNet(config, num_outputs=config.num_classes, norm_kwargs=dict(eps=.001, momentum=.01))\n    \n    # 将模型包装进验证/评估台 (DetBenchEval 内置 NMS 和边界框还原)\n    model = DetBenchEval(net, config)\n    \n    checkpoint = torch.load(checkpoint_path, map_location=device)\n    model.model.load_state_dict(checkpoint['model_state_dict'])\n    \n    model.to(device)\n    model.eval()\n    return model\n\ndevice = torch.device('cuda:0' if torch.cuda.is_available() else 'cpu')\n\n# 自动寻找保存的最优权重文件\nbest_checkpoints = sorted(glob(f'./{TrainGlobalConfig.folder}/best-checkpoint-*.bin'))\n\nif len(best_checkpoints) > 0:\n    best_model_path = best_checkpoints[-1]\n    print(f\"[INFO] 加载最优权重文件: {best_model_path}\")\n    inference_net = load_net_for_inference(best_model_path, device)\nelse:\n    print(\"[WARNING] 未找到最佳权重文件，将使用当前内存中的模型进行测试演示。\")\n    config = get_efficientdet_config('tf_efficientdet_d5')\n    config.num_classes = 1\n    config.image_size = 512\n    inference_net = DetBenchEval(net.model, config).to(device)\n    inference_net.eval()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T09:32:07.713884Z","iopub.execute_input":"2026-09-15T09:32:07.714179Z","iopub.status.idle":"2026-09-15T09:32:09.324259Z","shell.execute_reply.started":"2026-09-15T09:32:07.714155Z","shell.execute_reply":"2026-09-15T09:32:09.323526Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================\n# 批量执行前向推理（image_scales 形状设为 (batch_size, 4)）\n# ============================================================\n\nSCORE_THRESHOLD = 0.35  # 置信度过滤阈值\n\npredictions = []\n\nwith torch.no_grad():\n    for images, orig_shapes, image_ids in test_loader:\n        images = torch.stack(images).to(device).float()\n        batch_size = images.shape[0]\n\n        # 核心修复点：boxes 是 4 维坐标，image_scale 必须是 4 维 (ymin_scale, xmin_scale, ymax_scale, xmax_scale)\n        image_scales = torch.ones((batch_size, 4), device=device, dtype=torch.float32)\n\n        # 调用 DetBenchEval 进行推理\n        outputs = inference_net(images, image_scales=image_scales)\n\n        # 遍历批次中的每张图片检测结果\n        for i, output in enumerate(outputs):\n            orig_h, orig_w = orig_shapes[i]\n            # 计算还原到原图的缩放比 (模型输入是 512x512)\n            scale_y = orig_h / 512.0\n            scale_x = orig_w / 512.0\n\n            pred_boxes = []\n            pred_scores = []\n\n            for det in output:\n                # 每行对应: [ymin, xmin, ymax, xmax, score, class]\n                score = det[4].item()\n                if score < SCORE_THRESHOLD:\n                    continue\n\n                ymin, xmin, ymax, xmax = det[0].item(), det[1].item(), det[2].item(), det[3].item()\n\n                # 按原图尺寸等比缩放还原\n                ymin = max(0, ymin * scale_y)\n                xmin = max(0, xmin * scale_x)\n                ymax = min(orig_h, ymax * scale_y)\n                xmax = min(orig_w, xmax * scale_x)\n\n                pred_boxes.append([xmin, ymin, xmax, ymax])\n                pred_scores.append(score)\n\n            predictions.append({\n                'image_id': image_ids[i],\n                'boxes': np.array(pred_boxes),\n                'scores': np.array(pred_scores)\n            })\n\nprint(f\"[INFO] 测试集推理完毕，共生成 {len(predictions)} 张图片的检测结果。\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T09:33:39.840990Z","iopub.execute_input":"2026-09-15T09:33:39.841414Z","iopub.status.idle":"2026-09-15T09:33:40.763790Z","shell.execute_reply.started":"2026-09-15T09:33:39.841375Z","shell.execute_reply":"2026-09-15T09:33:40.762661Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 13. 测试集预测效果直观可视化\n\n我们将测试集图片的预测框与置信度分数绘制在原图上，验证模型的实际泛化能力。","metadata":{}},{"cell_type":"code","source":"# 抽取最多前 4 张测试图片进行预测效果绘图展示\nsamples_to_plot = predictions[:4]\n\nfor sample in samples_to_plot:\n    image_id = sample['image_id']\n    boxes = sample['boxes']\n    scores = sample['scores']\n    \n    orig_img_path = f'{TEST_ROOT_PATH}/{image_id}.jpg'\n    img = cv2.imread(orig_img_path, cv2.IMREAD_COLOR)\n    img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)\n    \n    for box, score in zip(boxes, scores):\n        xmin, ymin, xmax, ymax = box.astype(np.int32)\n        \n        # 绘制检测框（红色矩形框）\n        cv2.rectangle(img, (xmin, ymin), (xmax, ymax), (255, 0, 0), 3)\n        \n        # 在框上方绘制置信度文本标签\n        text = f'{score:.2f}'\n        cv2.putText(\n            img, text, (xmin, max(25, ymin - 10)),\n            cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 0, 0), 2\n        )\n    \n    plt.figure(figsize=(10, 10))\n    plt.imshow(img)\n    plt.title(f\"Test Image ID: {image_id} | Detected Wheat Heads: {len(boxes)}\", fontsize=14)\n    plt.axis('off')\n    plt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-09-15T09:33:48.753789Z","iopub.execute_input":"2026-09-15T09:33:48.754073Z","iopub.status.idle":"2026-09-15T09:33:50.173116Z","shell.execute_reply.started":"2026-09-15T09:33:48.754049Z","shell.execute_reply":"2026-09-15T09:33:50.172091Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 14. 总结与延伸学习\n\n恭喜你完成了整套 SOTA 目标检测模型训练与测试全流程！我们回顾一下本实战的核心收获：\n\n1. **Bbox 格式转换链条**：CSV 原始 `[x, y, w, h]` $\\rightarrow$ Albumentations `[xmin, ymin, xmax, ymax]` $\\rightarrow$ EfficientDet `[ymin, xmin, ymax, xmax]`[cite: 1] $\\rightarrow$ 原图输出 `[xmin, ymin, xmax, ymax]`。\n2. **增强技巧**：利用 4 图 CutMix / Mosaic 拼接，使模型在单张训练图片中同时学习密集小目标与稀疏大目标[cite: 1]。\n3. **训练与推理架构分离**：训练阶段使用带损失计算的 `DetBenchTrain`[cite: 1]；推理阶段使用内置 NMS 的 `DetBenchPredict` 并执行反向坐标缩放。\n4. **工程健壮性**：通过设置 `collate_fn` 打包非定长框[cite: 1]，并在测试阶段指定 `num_workers=0` 杜绝多进程队列悬空报错。","metadata":{}}]}