Files
ModelTrainEval/detect.py
T
Shaun ZhouandClaude Opus 5 154677032f Revert "约束预测框互不重叠"(eba533a、9439cbb)
去掉强制非重叠:detect.py 不再给 DetectionValidator.postprocess 打补丁,
配置里移除 train.max_overlap 与 agnostic_nms,可视化脚本恢复为直接使用模型原始输出,
engine/detection 里的 suppress_overlaps / filter_overlapping_outputs 一并移除。

约束会把 mAP50 从 0.555 压到 0.483(precision 0.536->0.612),代价是按置信度抑制时
高分错框会压掉本可命中的低分框。需要时用 git revert 这次提交即可取回。

按类别分目录的可视化产出结构(36fff98)保留。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:34:13 +08:00

158 lines
6.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python
"""在弱监督框标签上训练 YOLO 检测模型。
uv run python detect.py --config configs/detect/film.yaml
uv run python detect.py --config configs/detect/film.yaml --eval-only \
--checkpoint runs/detect/film/weights/best.pt
数据是在 Label Studio 里修正过的标签,导出并划分 train/val 之后放在 data/ 下的数据集。
框来自 Grad-CAM / SHAP 的弱监督标注,不是人工标注——指标该按"与热力区域的一致性"
来读,而不是当成真值上的检测精度。
"""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import yaml
from utils import load_config, seed_everything, setup_logging
from utils.device import DeviceNotAvailableError, describe_device, resolve_device
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="薄膜缺陷检测(YOLO)训练/评估")
parser.add_argument("--config", required=True, help="yaml 配置路径")
parser.add_argument("--set", nargs="*", default=[], metavar="KEY=VALUE", help="覆盖配置项")
parser.add_argument("--work-dir", default=None)
parser.add_argument("--device", default=None, help="auto / cuda / mps / cpu")
parser.add_argument("--resume", action="store_true", help="从 work_dir 里的 last.pt 续训")
parser.add_argument("--eval-only", action="store_true", help="只在验证集上评估")
parser.add_argument("--checkpoint", default=None, help="评估/微调用的权重 .pt")
return parser.parse_args()
def to_ultralytics_device(device) -> str | int:
"""torch.device -> ultralytics 的 device 记法(cuda 用序号,其余用字符串)。"""
if device.type == "cuda":
return device.index or 0
return device.type
def main() -> int:
args = parse_args()
cfg = load_config(args.config, overrides=args.set)
if args.work_dir:
cfg["work_dir"] = args.work_dir
work_dir = Path(cfg.get("work_dir", "runs/detect"))
work_dir.mkdir(parents=True, exist_ok=True)
logger = setup_logging(log_file=work_dir / "detect.log")
seed = int(cfg.get("seed", 42))
seed_everything(seed)
try:
device = resolve_device(args.device or cfg.get("device", "auto"))
except DeviceNotAvailableError as exc:
logger.error("%s", exc)
return 1
logger.info("设备: %s", describe_device(device))
data_yaml = Path(cfg["data"]["yaml"])
if not data_yaml.exists():
logger.error(
"找不到 %s;先把 Label Studio 里修好的标签导出、划分 train/val 到 data/ 下", data_yaml
)
return 1
meta = yaml.safe_load(data_yaml.read_text(encoding="utf-8"))
logger.info("数据集: %s(%d 类)", data_yaml, meta["nc"])
if meta.get("train") == meta.get("val"):
# generate_boxes.py 的产出就不划分,两者指向同一目录
logger.warning(
"data.yaml 的 train 与 val 相同(%s):验证集就是训练集,mAP 会虚高,"
"早停与 best.pt 的选择也因此失真。正式训练前请先划分数据集",
meta.get("train"),
)
train_cfg = dict(cfg.get("train", {}))
swanlab_cfg = dict(train_cfg.pop("swanlab", {}))
from ultralytics import YOLO
from ultralytics import settings as ul_settings
# ultralytics 默认把数据集根指到 ~/datasets 之类的全局目录;我们的 data.yaml 里写的是
# 绝对路径,但它仍会拿 datasets_dir 去拼相对路径,统一指到项目根省得踩坑。
ul_settings.update({"datasets_dir": str(Path.cwd())})
weights = args.checkpoint or cfg["model"]["weights"]
logger.info("模型: %s", weights)
model = YOLO(weights)
if swanlab_cfg.pop("enabled", False):
try:
from swanlab.integration.ultralytics import add_swanlab_callback
os.environ.setdefault("SWANLAB_PROJ_NAME", str(swanlab_cfg.get("project", "film-detect")))
import swanlab
swanlab.init(
project=swanlab_cfg.get("project", "film-detect"),
name=swanlab_cfg.get("name") or work_dir.name,
mode=swanlab_cfg.get("mode", "local"),
config=cfg,
log_dir=str(work_dir / "swanlog"),
tags=swanlab_cfg.get("tags") or [],
)
add_swanlab_callback(model)
logger.info("SwanLab 已接入 YOLO 回调")
except Exception as exc: # 可视化失败不该挡住训练
logger.warning("SwanLab 接入失败,训练继续: %s", exc)
# ultralytics 自己管 project/name 目录。project 必须给绝对路径:相对路径会被它再拼到
# 全局 settings 的 runs_dir 上,产出就跑到 runs/detect/runs/detect/<name> 去了。
common = {
"data": str(data_yaml.resolve()),
"device": to_ultralytics_device(device),
"project": str(work_dir.resolve().parent),
"name": work_dir.name,
"exist_ok": True,
"seed": seed,
}
if args.eval_only:
metrics = model.val(split="val", plots=True, **common)
else:
model.train(resume=args.resume, **common, **train_cfg)
# train 结束后 ultralytics 已在最优权重上跑过一次 val,这里显式再跑一次拿回指标对象
metrics = model.val(split="val", plots=True, **common)
summary = {
"map50_95": float(metrics.box.map),
"map50": float(metrics.box.map50),
"precision": float(metrics.box.mp),
"recall": float(metrics.box.mr),
"per_class_map50": {
meta["names"][int(c)]: float(v)
for c, v in zip(metrics.box.ap_class_index, metrics.box.ap50, strict=False)
},
}
logger.info(
"mAP50-95 %.4f | mAP50 %.4f | P %.4f | R %.4f",
summary["map50_95"], summary["map50"], summary["precision"], summary["recall"],
)
(work_dir / "metrics.json").write_text(
json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8"
)
logger.info(
"曲线与混淆矩阵见 %s(results.png / *_curve.png / confusion_matrix*.png)",
work_dir.resolve(),
)
return 0
if __name__ == "__main__":
raise SystemExit(main())