实验1:Faster R-CNN 水平框检测

本实验按完整检测工程组织:**数据准备 → 数据检查 → 构建数据集 → 构建模型 → 构建训练器 → 开始训练 → 评估验证集 → 推理与可视化 → 性能曲线**。

`RUN_TRAINING=0` 只执行数据检查和模型构建;`RUN_TRAINING=1` 才启动训练。教学子集仅用于控制课堂运行规模,不替代完整数据集的正式实验。

1 实验参数与环境准备

from pathlib import Path
import json, os, sys, time, platform

ROOT = Path.cwd()
while not (ROOT / "pyproject.toml").exists() and ROOT != ROOT.parent:
    ROOT = ROOT.parent
sys.path.insert(0, str(ROOT / "src"))
from rsdet.notebook_utils import set_seed, build_coco_teaching_subset, coco_summary, print_manifest, find_first_image

SEED = int(os.getenv("SEED", "42"))
set_seed(SEED)
RUN_TRAINING = bool(int(os.getenv("RUN_TRAINING", "0")))
SUBSET_MAX_IMAGES = int(os.getenv("SUBSET_MAX_IMAGES", "3000"))
SUBSET_MAX_CLASSES = int(os.getenv("SUBSET_MAX_CLASSES", "6"))
DATA_ROOT = Path(os.getenv("DATA_ROOT", str(ROOT / "data" / "processed" / "dior")))
RUN_ROOT = ROOT / "runs" / "notebook_outputs"
RUN_ROOT.mkdir(parents=True, exist_ok=True)
ARTIFACTS = {"seed": SEED, "platform": platform.platform(), "python": platform.python_version()}
print({"root": str(ROOT), "run_training": RUN_TRAINING, "subset_max_images": SUBSET_MAX_IMAGES, "subset_max_classes": SUBSET_MAX_CLASSES, "seed": SEED})

def save_json(name, payload):
    path = RUN_ROOT / name
    path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
    ARTIFACTS[name] = str(path)
    return path

def plot_history(history, name="training_curves.png"):
    """Save loss/learning-rate curves when a real run produced history."""
    if not history:
        print("暂无训练历史;设置 RUN_TRAINING=1 后会保存曲线。")
        return None
    try:
        import matplotlib.pyplot as plt
    except ImportError:
        print("matplotlib 未安装,跳过曲线绘制。")
        return None
    epochs = [row.get("epoch", i + 1) for i, row in enumerate(history)]
    fig, axes = plt.subplots(1, 2, figsize=(10, 4))
    axes[0].plot(epochs, [row.get("loss", float("nan")) for row in history], marker="o")
    axes[0].set_title("loss"); axes[0].set_xlabel("epoch"); axes[0].grid(alpha=.3)
    axes[1].plot(epochs, [row.get("lr", float("nan")) for row in history], marker="o", color="darkorange")
    axes[1].set_title("learning rate"); axes[1].set_xlabel("epoch"); axes[1].grid(alpha=.3)
    fig.tight_layout()
    path = RUN_ROOT / name; fig.savefig(path, dpi=150); plt.close(fig)
    ARTIFACTS[name] = str(path)
    print("曲线已保存:", path)
    return path
print("建议环境:Python 3.9、PyTorch 2.0、torchvision 0.15;GPU 训练优先使用 CUDA。")
print("本次运行目录:", RUN_ROOT.resolve())

2 DIOR 数据准备:DIOR → COCO 格式

# SOURCE_COCO_JSON 是 DIOR 转换得到的完整 COCO 标注;函数只读取源数据,
# 再把图像—标注配对复制到 RUN_ROOT 下的教学子集目录。
SOURCE_COCO_JSON = Path(os.getenv("COCO_JSON", str(DATA_ROOT / "annotations.json")))
SOURCE_IMAGE_DIR = Path(os.getenv("IMAGE_DIR", str(DATA_ROOT / "images")))
if not SOURCE_COCO_JSON.exists():
    candidates = [DATA_ROOT / "annotations" / "train.json", DATA_ROOT / "train.json"]
    SOURCE_COCO_JSON = next((path for path in candidates if path.exists()), SOURCE_COCO_JSON)
if not SOURCE_COCO_JSON.exists():
    raise FileNotFoundError(f"DIOR 的 COCO 标注不存在: {SOURCE_COCO_JSON.resolve()}")
if not SOURCE_IMAGE_DIR.exists():
    raise FileNotFoundError(f"DIOR 图像目录不存在: {SOURCE_IMAGE_DIR.resolve()}")
SUBSET_ROOT = RUN_ROOT / "faster_rcnn_subset"
subset_info = build_coco_teaching_subset(
    SOURCE_COCO_JSON, SUBSET_ROOT / "annotations.json",
    max_images=SUBSET_MAX_IMAGES, max_classes=SUBSET_MAX_CLASSES, seed=SEED,
    source_image_dir=SOURCE_IMAGE_DIR,
)
COCO_JSON = Path(subset_info["annotation"])
IMAGE_DIR = SUBSET_ROOT
ARTIFACTS["subset_manifest"] = str(subset_info["manifest"])
summary = coco_summary(COCO_JSON)
ARTIFACTS["coco_summary"] = summary
print("DIOR → COCO 教学子集准备完成:", subset_info, summary)

3 数据检查与标注可视化

with COCO_JSON.open(encoding="utf-8") as stream:
    coco = json.load(stream)
print("图像数:", len(coco["images"]), "标注数:", len(coco["annotations"]), "类别:", coco["categories"])
ann_counts = {}
for ann in coco["annotations"]:
    ann_counts[ann["category_id"]] = ann_counts.get(ann["category_id"], 0) + 1
print("类别实例统计:", ann_counts)
first = find_first_image(IMAGE_DIR)
if first:
    try:
        from PIL import Image
        import matplotlib.pyplot as plt
        image = Image.open(first).convert("RGB")
        plt.figure(figsize=(5, 5)); plt.imshow(image); plt.title(first.name); plt.axis("off"); plt.show()
    except ImportError:
        print("Pillow 或 matplotlib 未安装,跳过图像显示。")

4 构建数据集与 DataLoader

import numpy as np
from PIL import Image
try:
    import torch
    from torch.utils.data import Dataset, DataLoader
    TORCH_READY = True
except ImportError as exc:
    torch = None; Dataset = object; DataLoader = None; TORCH_READY = False
    print("PyTorch 未安装:", exc)

cat_ids = sorted(item["id"] for item in coco["categories"])
cat_to_label = {cat_id: i + 1 for i, cat_id in enumerate(cat_ids)}
images = {item["id"]: item for item in coco["images"]}
ann_by_image = {image_id: [] for image_id in images}
for ann in coco["annotations"]: ann_by_image.setdefault(ann["image_id"], []).append(ann)

class CocoDetection(Dataset):
    """把 COCO xywh 转为 torchvision xyxy;不在这里做随机增强,便于复核。"""
    def __init__(self, image_dir, records, annotations): self.image_dir, self.records, self.annotations = Path(image_dir), records, annotations
    def __len__(self): return len(self.records)
    def __getitem__(self, index):
        record = self.records[index]
        image = torch.as_tensor(np.asarray(Image.open(self.image_dir / record["file_name"]).convert("RGB")).transpose(2, 0, 1), dtype=torch.float32) / 255.0
        boxes, labels, areas = [], [], []
        for ann in self.annotations.get(record["id"], []):
            x, y, w, h = ann["bbox"]; boxes.append([x, y, x + w, y + h]); labels.append(cat_to_label[ann["category_id"]]); areas.append(w * h)
        target = {"boxes": torch.tensor(boxes, dtype=torch.float32), "labels": torch.tensor(labels, dtype=torch.int64), "image_id": torch.tensor([record["id"]]), "area": torch.tensor(areas, dtype=torch.float32), "iscrowd": torch.zeros(len(boxes), dtype=torch.int64)}
        return image, target

def collate_fn(batch): return tuple(zip(*batch))
records = list(images.values()); split = max(1, int(len(records) * .8))
if TORCH_READY:
    train_ds = CocoDetection(IMAGE_DIR, records[:split], ann_by_image); val_ds = CocoDetection(IMAGE_DIR, records[split:] or records[:1], ann_by_image)
    train_loader = DataLoader(train_ds, batch_size=int(os.getenv("BATCH", "2")), shuffle=True, collate_fn=collate_fn)
    val_loader = DataLoader(val_ds, batch_size=1, shuffle=False, collate_fn=collate_fn)
    print("训练/验证图像数:", len(train_ds), len(val_ds))
else:
    train_ds = val_ds = train_loader = val_loader = None

5 构建模型

model = None; device = None
if TORCH_READY:
    try:
        from torchvision.models.detection import fasterrcnn_resnet50_fpn
        from torchvision.models.detection import FasterRCNN_ResNet50_FPN_Weights
        from torchvision.models.detection.faster_rcnn import FastRCNNPredictor
        weights = FasterRCNN_ResNet50_FPN_Weights.DEFAULT if os.getenv("USE_PRETRAINED", "0") == "1" else None
        model = fasterrcnn_resnet50_fpn(weights=weights, min_size=320, max_size=640)
        model.roi_heads.box_predictor = FastRCNNPredictor(model.roi_heads.box_predictor.cls_score.in_features, len(cat_ids) + 1)
        device = torch.device("cuda" if torch.cuda.is_available() else "cpu"); model.to(device)
        print("模型构建完成:", type(model).__name__, "device=", device)
    except ImportError as exc:
        print("torchvision detection 未就绪:", exc)

6 构建训练器

optimizer = scheduler = None
if model is not None:
    optimizer = torch.optim.SGD([p for p in model.parameters() if p.requires_grad], lr=float(os.getenv("LR", "0.005")), momentum=.9, weight_decay=.0005)
    scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=3, gamma=.1)
    print("优化器:", type(optimizer).__name__, "初始学习率:", optimizer.param_groups[0]["lr"])

def train_one_epoch(model, loader, optimizer, device):
    model.train(); losses = []
    for image_batch, target_batch in loader:
        image_batch = [x.to(device) for x in image_batch]; target_batch = [{k: v.to(device) for k, v in t.items()} for t in target_batch]
        loss_dict = model(image_batch, target_batch); loss = sum(loss_dict.values())
        optimizer.zero_grad(); loss.backward(); optimizer.step(); losses.append(float(loss.detach().cpu()))
    return sum(losses) / max(1, len(losses))

def evaluate_predictions(model, loader, device):
    model.eval(); count = 0; boxes = 0
    with torch.no_grad():
        for image_batch, _ in loader:
            output = model([x.to(device) for x in image_batch]); count += len(output); boxes += sum(len(item["boxes"]) for item in output)
    return {"images": count, "predictions": boxes}

7 开始训练

history = []
if model is None:
    print("模型不可用,完成数据链路检查后跳过训练。")
elif RUN_TRAINING:
    for epoch in range(int(os.getenv("EPOCHS", "1"))):
        loss = train_one_epoch(model, train_loader, optimizer, device); scheduler.step()
        row = {"epoch": epoch + 1, "loss": loss, "lr": optimizer.param_groups[0]["lr"]}; history.append(row); print(row)
    checkpoint = RUN_ROOT / "faster_rcnn_checkpoint.pt"
    torch.save({"model": model.state_dict(), "categories": summary["category_names"], "seed": SEED}, checkpoint)
    ARTIFACTS["checkpoint"] = str(checkpoint)
else:
    print("RUN_TRAINING=0:跳过长时间训练。")
save_json("faster_rcnn_history.json", history)

8 评估验证集与计算指标

evaluation = {}
if model is not None:
    evaluation = evaluate_predictions(model, val_loader, device)
    print("教学子集验证集预测统计:", evaluation)
else:
    print("无模型输出;正式运行时应接入 COCOeval,报告 mAP50、mAP50:95、Precision、Recall。")
save_json("faster_rcnn_evaluation.json", evaluation)

9 测试集推理与结果可视化

inference_record = {"status": "skipped", "reason": "模型未训练或依赖未安装"}
if model is not None and RUN_TRAINING and first:
    model.eval()
    with torch.no_grad():
        image = torch.as_tensor(np.asarray(Image.open(first).convert("RGB")).transpose(2, 0, 1), dtype=torch.float32).to(device) / 255.0
        output = model([image])[0]
    inference_record = {"image": str(first), "boxes": int(len(output["boxes"])), "score_threshold": .25}
    print("推理结果:", inference_record)
save_json("faster_rcnn_inference.json", inference_record)

10 绘制损失、学习率与结果曲线

plot_history(history, "faster_rcnn_curves.png")
print("最终产物:", ARTIFACTS)
print_manifest(RUN_ROOT, **ARTIFACTS)

结果解释

报告需区分数据检查、教学子集训练和完整数据正式测试三类结果。正式结果至少包含逐类别 AP、mAP50、mAP50:95、Precision、Recall、FPS、显存、错误样本和运行命令。