{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "e342b210",
   "metadata": {},
   "source": [
    "# 实验3：Oriented R-CNN 旋转框检测\n",
    "\n",
    "流程：**读取完整 DOTA → 构建 trainval/val 教学子集 → 标注检查 → 切片与格式确认 → 构建 MMRotate 配置 → 构建训练/测试命令 → 训练 → OBB 指标评估 → 推理可视化 → 角度误差分析**。MMRotate 0.3.3 使用独立旧版环境，不能与 MMDetection 3.x 混装。原始图像和标注保持不变。"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 1 参数与环境准备"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# pathlib：跨平台处理项目路径；json：保存实验清单；os：读取环境变量；\n",
    "# sys：把项目源码加入导入路径；time：测速；platform：记录运行平台。\n",
    "from pathlib import Path\n",
    "import json, os, sys, time, platform\n",
    "\n",
    "ROOT = Path.cwd()\n",
    "while not (ROOT / \"pyproject.toml\").exists() and ROOT != ROOT.parent:\n",
    "    ROOT = ROOT.parent\n",
    "sys.path.insert(0, str(ROOT / \"src\"))\n",
    "from rsdet.notebook_utils import (\n",
    "    set_seed,\n",
    "    build_coco_teaching_subset,\n",
    "    build_yolo_teaching_subset,\n",
    "    build_dota_teaching_subset,\n",
    "    read_yolo_names,\n",
    "    coco_summary,\n",
    "    print_manifest,\n",
    "    find_first_image,\n",
    ")\n",
    "\n",
    "SEED = int(os.getenv(\"SEED\", \"42\"))\n",
    "set_seed(SEED)\n",
    "RUN_TRAINING = bool(int(os.getenv(\"RUN_TRAINING\", \"0\")))\n",
    "# Notebook 读取完整数据源，再写出可追溯的教学子集；原始目录不被覆盖。\n",
    "SUBSET_MAX_IMAGES = int(os.getenv(\"SUBSET_MAX_IMAGES\", \"3000\"))\n",
    "SUBSET_MAX_CLASSES = int(os.getenv(\"SUBSET_MAX_CLASSES\", \"6\"))\n",
    "DATA_ROOT = Path(os.getenv(\"DATA_ROOT\", str(ROOT / \"data\" / \"processed\" / \"dior\")))\n",
    "RUN_ROOT = ROOT / \"runs\" / \"notebook_outputs\"\n",
    "RUN_ROOT.mkdir(parents=True, exist_ok=True)\n",
    "ARTIFACTS = {\"seed\": SEED, \"platform\": platform.platform(), \"python\": platform.python_version()}\n",
    "print({\"root\": str(ROOT), \"run_training\": RUN_TRAINING, \"subset_max_images\": SUBSET_MAX_IMAGES, \"subset_max_classes\": SUBSET_MAX_CLASSES, \"seed\": SEED})\n",
    "\n",
    "def save_json(name, payload):\n",
    "    \"\"\"将阶段性结果写入 runs/notebook_outputs，方便报告引用和复核。\"\"\"\n",
    "    path = RUN_ROOT / name\n",
    "    path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, default=str), encoding=\"utf-8\")\n",
    "    ARTIFACTS[name] = str(path)\n",
    "    return path\n",
    "\n",
    "def plot_history(history, name=\"training_curves.png\"):\n",
    "    \"\"\"读取训练历史并绘制 loss、学习率曲线；无历史时只给出提示。\"\"\"\n",
    "    if not history:\n",
    "        print(\"暂无训练历史；设置 RUN_TRAINING=1 后会保存曲线。\")\n",
    "        return None\n",
    "    try:\n",
    "        import matplotlib.pyplot as plt\n",
    "    except ImportError:\n",
    "        print(\"matplotlib 未安装，跳过曲线绘制。\")\n",
    "        return None\n",
    "    epochs = [row.get(\"epoch\", i + 1) for i, row in enumerate(history)]\n",
    "    fig, axes = plt.subplots(1, 2, figsize=(10, 4))\n",
    "    axes[0].plot(epochs, [row.get(\"loss\", float(\"nan\")) for row in history], marker=\"o\")\n",
    "    axes[0].set_title(\"loss\"); axes[0].set_xlabel(\"epoch\"); axes[0].grid(alpha=.3)\n",
    "    axes[1].plot(epochs, [row.get(\"lr\", float(\"nan\")) for row in history], marker=\"o\", color=\"darkorange\")\n",
    "    axes[1].set_title(\"learning rate\"); axes[1].set_xlabel(\"epoch\"); axes[1].grid(alpha=.3)\n",
    "    fig.tight_layout()\n",
    "    path = RUN_ROOT / name; fig.savefig(path, dpi=150); plt.close(fig)\n",
    "    ARTIFACTS[name] = str(path)\n",
    "    print(\"曲线已保存:\", path)\n",
    "    return path"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 2 DOTA v1.0 数据准备：DOTA v1.0 → DOTA labelTxt 格式"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# dota_summary 解析 DOTA labelTxt；build_dota_teaching_subset 复制配对文件，\n",
    "# 生成可追溯的小规模 trainval/val 目录，不会修改原始 DOTA。\n",
    "from rsdet.notebook_utils import dota_summary\n",
    "MMROTATE_ROOT = Path(os.getenv(\"MMROTATE_ROOT\", str(ROOT / \"third_party\" / \"mmrotate-v0.3.3\")))\n",
    "SOURCE_DOTA_ROOT = Path(os.getenv(\"DOTA_ROOT\", str(ROOT / \"data\" / \"raw\" / \"DOTA\")))\n",
    "SOURCE_TRAIN_ROOT = Path(os.getenv(\"DOTA_TRAIN_ROOT\", str(SOURCE_DOTA_ROOT / \"trainval\")))\n",
    "SOURCE_VAL_ROOT = Path(os.getenv(\"DOTA_VAL_ROOT\", str(SOURCE_DOTA_ROOT / \"val\")))\n",
    "SUBSET_DOTA_ROOT = RUN_ROOT / \"oriented_rcnn_subset\"\n",
    "train_subset = build_dota_teaching_subset(\n",
    "    SOURCE_TRAIN_ROOT / \"images\", SOURCE_TRAIN_ROOT / \"labelTxt\", SUBSET_DOTA_ROOT,\n",
    "    max_images=SUBSET_MAX_IMAGES, seed=SEED, split=\"trainval\",\n",
    ")\n",
    "val_subset = None\n",
    "if (SOURCE_VAL_ROOT / \"images\").is_dir() and (SOURCE_VAL_ROOT / \"labelTxt\").is_dir():\n",
    "    val_subset = build_dota_teaching_subset(\n",
    "        SOURCE_VAL_ROOT / \"images\", SOURCE_VAL_ROOT / \"labelTxt\", SUBSET_DOTA_ROOT,\n",
    "        max_images=max(1, SUBSET_MAX_IMAGES // 5), seed=SEED, split=\"val\",\n",
    "    )\n",
    "DOTA_ROOT = SUBSET_DOTA_ROOT\n",
    "ANN_DIR = DOTA_ROOT / \"trainval\" / \"labelTxt\"\n",
    "os.environ[\"DOTA_ROOT\"] = str(DOTA_ROOT)\n",
    "os.environ[\"DOTA_TRAIN_ROOT\"] = str(DOTA_ROOT / \"trainval\")\n",
    "if val_subset:\n",
    "    os.environ[\"DOTA_VAL_ROOT\"] = str(DOTA_ROOT / \"val\")\n",
    "ARTIFACTS[\"subset_manifest_trainval\"] = str(train_subset[\"manifest\"])\n",
    "if val_subset:\n",
    "    ARTIFACTS[\"subset_manifest_val\"] = str(val_subset[\"manifest\"])\n",
    "CFG = ROOT / \"configs\" / \"oriented_rcnn_remote_sensing.py\"\n",
    "print(\"完整 DOTA 已处理为教学子集:\", {\"trainval\": train_subset, \"val\": val_subset}, {\"mmrotate_root\": str(MMROTATE_ROOT), \"dota_root\": str(DOTA_ROOT), \"annotation_dir\": str(ANN_DIR), \"config\": str(CFG)})"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 3 标注检查与数据统计"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "if ANN_DIR.exists():\n",
    "    dota = dota_summary(ANN_DIR); print(\"DOTA 摘要:\", dota)\n",
    "    if dota[\"invalid_rows\"]: raise ValueError(f\"非法标注行: {dota['invalid_rows']}\")\n",
    "else:\n",
    "    print(\"未发现真实 labelTxt；当前完成目录和配置审计。\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 4 构建数据集与切片索引"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# 先检查每幅原图是否存在同名标注，防止训练时静默丢弃样本。\n",
    "image_dir = DOTA_ROOT / \"trainval\" / \"images\"\n",
    "image_files = [p for p in image_dir.glob(\"*\") if p.suffix.lower() in {\".jpg\", \".png\", \".tif\", \".tiff\"}] if image_dir.exists() else []\n",
    "annotation_stems = {p.stem for p in ANN_DIR.glob(\"*.txt\")} if ANN_DIR.exists() else set()\n",
    "image_stems = {p.stem for p in image_files}\n",
    "pair_summary = {\"images\": len(image_files), \"annotations\": len(annotation_stems), \"missing_annotations\": sorted(image_stems - annotation_stems)[:10], \"orphan_annotations\": sorted(annotation_stems - image_stems)[:10]}\n",
    "print(\"图像—标注配对:\", pair_summary)\n",
    "print(\"切片索引原则：原图级划分后再切片，并记录 source_image、tile_id、offset 和边界裁剪信息。\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 5 构建数据处理配置"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# 读取配置文本只做关键字段审计；真正的模型实例由 MMRotate 的 train.py 构建。\n",
    "if not CFG.exists(): raise FileNotFoundError(CFG)\n",
    "cfg_text = CFG.read_text(encoding=\"utf-8\")\n",
    "print(\"配置行数:\", len(cfg_text.splitlines()), \"DOTA 类别数预期: 15\")\n",
    "print(\"切片前置：先按原始大图划分 train/val，再切片；禁止同一原图切片跨集合。\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 6 构建模型"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import importlib.util, subprocess\n",
    "# importlib.util 检查依赖是否可导入；subprocess 调用 MMRotate 官方训练脚本。\n",
    "required = [\"mmcv\", \"mmrotate\"]\n",
    "availability = {name: importlib.util.find_spec(name) is not None for name in required}\n",
    "TRAIN_SCRIPT, TEST_SCRIPT = MMROTATE_ROOT / \"tools\" / \"train.py\", MMROTATE_ROOT / \"tools\" / \"test.py\"\n",
    "print(\"依赖状态:\", availability, \"train.py:\", TRAIN_SCRIPT.exists(), \"test.py:\", TEST_SCRIPT.exists())"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 7 构建训练器"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def run_mmrotate(stage=\"train\"):\n",
    "    \"\"\"根据 stage 组装并执行 MMRotate 官方 train.py 或 test.py 命令。\"\"\"\n",
    "    script = TRAIN_SCRIPT if stage == \"train\" else TEST_SCRIPT\n",
    "    if not script.exists(): raise FileNotFoundError(script)\n",
    "    work_dir = RUN_ROOT / \"oriented_rcnn\"\n",
    "    cmd = [sys.executable, str(script), str(CFG)]\n",
    "    if stage == \"train\": cmd += [\"--work-dir\", str(work_dir)]\n",
    "    else: cmd += [str(work_dir / \"latest.pth\")]\n",
    "    print(\"执行命令:\", \" \".join(map(str, cmd))); return subprocess.run(cmd, cwd=MMROTATE_ROOT, check=True)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 8 开始训练"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "if RUN_TRAINING:\n",
    "    if not availability[\"mmrotate\"] or not DOTA_ROOT.exists(): raise RuntimeError(\"MMRotate 或 DOTA 数据未准备好\")\n",
    "    run_mmrotate(\"train\")\n",
    "else: print(\"RUN_TRAINING=0：完成配置审计，跳过训练。\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 9 测试集评估与 OBB 性能指标"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "if RUN_TRAINING and TEST_SCRIPT.exists(): run_mmrotate(\"test\")\n",
    "print(\"正式评估应记录 OBB-mAP、逐类别 AP、角度误差、边界漏检和水平框基线差异。\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 10 测试集推理与旋转框可视化"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(\"推理输出应保存预测四边形、原图叠加图和角度/长宽交换错误样本。\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 11 角度误差与结果曲线"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "log_candidates = [RUN_ROOT / \"oriented_rcnn\" / \"log.json\", RUN_ROOT / \"oriented_rcnn\" / \"metrics.json\"]\n",
    "log_file = next((p for p in log_candidates if p.exists()), None)\n",
    "if log_file:\n",
    "    try:\n",
    "        import matplotlib.pyplot as plt\n",
    "        rows = [json.loads(line) for line in log_file.read_text(encoding=\"utf-8\").splitlines() if line.strip()]\n",
    "        epochs = [r.get(\"epoch\") for r in rows if r.get(\"epoch\") is not None]\n",
    "        losses = [r.get(\"loss\") for r in rows if r.get(\"loss\") is not None]\n",
    "        if epochs and losses:\n",
    "            fig, ax = plt.subplots(figsize=(7, 4)); ax.plot(epochs, losses, marker=\"o\")\n",
    "            ax.set(xlabel=\"epoch\", ylabel=\"loss\", title=\"Oriented R-CNN loss\"); ax.grid(alpha=.3)\n",
    "            curve = RUN_ROOT / \"oriented_rcnn_curves.png\"; fig.tight_layout(); fig.savefig(curve, dpi=150); plt.close(fig)\n",
    "            ARTIFACTS[\"curve\"] = str(curve); print(\"曲线已保存:\", curve)\n",
    "        else:\n",
    "            print(\"日志存在，但尚未提供可绘制的 epoch/loss 字段。\")\n",
    "    except (ImportError, json.JSONDecodeError) as exc:\n",
    "        print(\"日志曲线绘制跳过:\", exc)\n",
    "else:\n",
    "    print(\"尚无 MMRotate 日志；正式训练后将绘制 loss、学习率、逐类 AP、角度误差和尺度—Recall 曲线。\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 12 实验结论与排错"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(\"若角度跳变或长宽交换，先检查顶点顺序、角度范围和标注转换，不要先更换模型。\")\n",
    "save_json(\"oriented_rcnn_audit.json\", {\"annotation_dir\": str(ANN_DIR), \"config\": str(CFG), \"dependencies\": availability})\n",
    "print_manifest(RUN_ROOT, **ARTIFACTS)"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python",
   "version": "3.9+"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
