feat: 用 Grad-CAM 从分类模型生成弱监督框标签

分类模型只学过"是什么缺陷",Grad-CAM 把分类分数对卷积特征的梯度当作通道重要性,
加权求和得到空间热力图,阈值化 + 连通域外接矩形即得到框标签,输出 YOLO 格式,
可直接接入既有的检测训练链路。

- engine/gradcam.py:整批一次 backward 的 Grad-CAM(各样本只回传自己那一类的分数)、
  numpy 实现的 4-邻接连通域标记(不为这一处引入 scipy/opencv)、CAM 转归一化框
- scripts/gradcam_boxes.py:遍历数据集生成标签,并输出「原图 | 热力图 | 叠加+框」
  三联图供人工核对;框的类别取自图片所在目录而非模型预测

两处按实图定下的默认值:
- --stage 默认 6(layer3, 8×8)。layer4 只有 4×4 太粗,layer2 响应太散导致 40% 出不了框
- --peak-ratio 0.75:次要连通域峰值需达全局最大值的 75%,否则会在无缺陷的角落刷假框;
  实测把两框比例从 53% 降到 20%

全量 11,212 张已生成:61.8% 出 1 框、35.2% 出 2 框、3.0% 无框,每类留 25 张可视化。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Shaun Zhou
2026-09-10 15:46:30 +08:00
co-authored by Claude Opus 5
parent 33fdf396ee
commit fc7136fddf
4 changed files with 631 additions and 0 deletions
+247
View File
@@ -0,0 +1,247 @@
#!/usr/bin/env python
"""用训练好的分类模型 + Grad-CAM 为每张图生成框标签。
uv run python scripts/gradcam_boxes.py --checkpoint runs/classify/best.pth
分类模型只学过"这是什么缺陷"。Grad-CAM 把分类分数对卷积特征的梯度当作通道重要性,
加权求和得到空间热力图——这是分类模型隐含的定位线索。阈值化 + 取连通域外接矩形,
就得到弱监督的框标签。
产出:
```
runs/gradcam_boxes/
├── labels/<类别>/<图名>.txt # YOLO 格式:class_id cx cy w h(归一化)
├── viz/<类别>/<图名>.png # 原图 | 热力图 | 叠加+框,供人工核对匹配程度
├── data.yaml # 类别表,可直接喂给检测训练
└── summary.json
```
**框的类别取自图片所在目录**(人工归类的结果),不是模型预测——即使模型分错,
我们要的也是该图真实类别的热力区域。
分辨率是主要限制:resnet18 在 128×128 输入下 layer4 只有 4×4、layer3 有 8×8。
`--stage` 可选层(4~7 对应 layer1~4),层越浅越精细但类别判别性越弱。
"""
from __future__ import annotations
import argparse
import json
import sys
from collections import Counter
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import numpy as np # noqa: E402
import torch # noqa: E402
from PIL import Image # noqa: E402
from torch.utils.data import DataLoader # noqa: E402
from datasets import FolderClassificationDataset, classification_collate # noqa: E402
from datasets.yolo import write_data_yaml # noqa: E402
from engine import load_checkpoint # noqa: E402
from engine.gradcam import cam_to_boxes, grad_cam, to_yolo_line # noqa: E402
from models import build_model # noqa: E402
from utils import load_config, setup_logging # noqa: E402
from utils.device import DeviceNotAvailableError, describe_device, resolve_device # noqa: E402
logger = None
def save_visualization(path: Path, image_path: Path, cam: np.ndarray, boxes, title: str) -> None:
"""三联图:原图 | 热力图 | 叠加+框。人工一眼就能看出热力区域与框合不合。"""
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle
with Image.open(image_path) as raw:
image = np.asarray(raw.convert("RGB"))
height, width = image.shape[:2]
heat = np.asarray(
Image.fromarray((cam * 255).astype(np.uint8)).resize((width, height), Image.BILINEAR)
) / 255.0
fig, axes = plt.subplots(1, 3, figsize=(11, 4))
axes[0].imshow(image)
axes[0].set_title("original")
axes[1].imshow(heat, cmap="jet", vmin=0, vmax=1)
axes[1].set_title(f"grad-cam {cam.shape[0]}x{cam.shape[1]}")
axes[2].imshow(image)
axes[2].imshow(heat, cmap="jet", alpha=0.45, vmin=0, vmax=1)
for x1, y1, x2, y2 in boxes:
axes[2].add_patch(
Rectangle(
(x1 * width, y1 * height), (x2 - x1) * width, (y2 - y1) * height,
fill=False, edgecolor="lime", linewidth=2,
)
)
axes[2].set_title(f"overlay + {len(boxes)} box")
for ax in axes:
ax.axis("off")
fig.suptitle(title, fontsize=10)
fig.tight_layout()
path.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(path, dpi=110)
plt.close(fig)
def main() -> int:
global logger
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument("--checkpoint", default="runs/classify/best.pth", help="分类模型权重")
parser.add_argument("--config", default="configs/classify/film.yaml", help="训练时的配置")
parser.add_argument("--out", default="runs/gradcam_boxes", help="输出目录")
parser.add_argument("--set", nargs="*", default=[], metavar="KEY=VALUE")
parser.add_argument("--device", default=None)
parser.add_argument(
"--stage", type=int, default=6,
help="取骨架 features 的第几层做 CAM(4~7 = layer1~4);默认 6 即 layer3",
)
parser.add_argument("--threshold", type=float, default=0.4, help="热力图阈值(相对最大值)")
parser.add_argument("--padding", type=float, default=0.1, help="框四周外扩比例")
parser.add_argument("--max-boxes", type=int, default=2, help="每张图最多几个框")
parser.add_argument(
"--min-area-ratio", type=float, default=0.02, help="连通域面积下限(占整图比例)"
)
parser.add_argument(
"--peak-ratio", type=float, default=0.75,
help="次要连通域的峰值至少要达到全局最大值的这个比例才保留,用于滤掉弱响应假框",
)
parser.add_argument(
"--viz-per-class", type=int, default=20, help="每个类别存多少张可视化,0 = 不存"
)
parser.add_argument("--viz-all", action="store_true", help="每张图都存可视化(很慢很占盘)")
parser.add_argument("--limit", type=int, default=0, help="只处理前 N 张,用于快速试参数")
args = parser.parse_args()
out_dir = Path(args.out)
out_dir.mkdir(parents=True, exist_ok=True)
logger = setup_logging(out_dir / "gradcam.log")
cfg = load_config(args.config, args.set)
try:
device = resolve_device(args.device or cfg.get("device", "auto"))
except DeviceNotAvailableError as exc:
logger.error("%s", exc)
return 1
data_cfg = cfg["data"]
dataset = FolderClassificationDataset(
root=data_cfg["root"],
split="all", # 为每个样本都生成,不分训练/验证
classes=data_cfg.get("classes"),
transforms=data_cfg["val_transforms"],
exclude=data_cfg.get("exclude") or [],
)
if args.limit:
dataset.samples = dataset.samples[: args.limit]
classes = dataset.classes
model_cfg = dict(cfg["model"])
model_cfg.setdefault("num_classes", len(classes))
model_cfg["init_from"] = None # 权重从 checkpoint 来,不要再读自监督骨架
model = build_model(model_cfg)
load_checkpoint(args.checkpoint, model, map_location=device)
model.to(device).eval()
for param in model.parameters(): # Grad-CAM 需要梯度,但不更新权重
param.requires_grad_(True)
logger.info("设备: %s | 权重: %s", describe_device(device), args.checkpoint)
logger.info("数据集 %s:%d 张,%d 类", data_cfg["root"], len(dataset), len(classes))
logger.info(
"CAM 目标层 features[%d] | 阈值 %.2f | 外扩 %.2f | 每图最多 %d 框",
args.stage, args.threshold, args.padding, args.max_boxes,
)
loader = DataLoader(
dataset,
batch_size=int(data_cfg.get("loader", {}).get("batch_size", 64)),
shuffle=False,
num_workers=int(data_cfg.get("loader", {}).get("num_workers", 4)),
collate_fn=classification_collate,
)
labels_dir = out_dir / "labels"
viz_dir = out_dir / "viz"
viz_done: Counter[str] = Counter()
box_counts: Counter[int] = Counter()
per_class_boxes: Counter[str] = Counter()
correct = 0
cam_shape = None
for step, batch in enumerate(loader):
images = batch["images"].to(device)
targets = batch["labels"].to(device)
cams = grad_cam(model.backbone, model.head, images, targets, stage=args.stage)
with torch.no_grad():
correct += int((model(images).argmax(1) == targets).sum())
cam_shape = tuple(cams.shape[-2:])
for cam, target, meta in zip(cams.cpu().numpy(), targets.cpu().tolist(), batch["meta"],
strict=True):
name = classes[target]
boxes = cam_to_boxes(
cam,
threshold=args.threshold,
min_area_ratio=args.min_area_ratio,
max_boxes=args.max_boxes,
padding=args.padding,
peak_ratio=args.peak_ratio,
)
box_counts[len(boxes)] += 1
per_class_boxes[name] += len(boxes)
source = Path(meta["path"])
label_path = labels_dir / name / f"{source.stem}.txt"
label_path.parent.mkdir(parents=True, exist_ok=True)
label_path.write_text(
"\n".join(to_yolo_line(box, target) for box in boxes) + ("\n" if boxes else ""),
encoding="utf-8",
)
if args.viz_all or viz_done[name] < args.viz_per_class:
viz_done[name] += 1
save_visualization(
viz_dir / name / f"{source.stem}.png", source, cam, boxes,
f"{name} | {len(boxes)} box | thr={args.threshold}",
)
if step % 20 == 0:
logger.info("处理 [%d/%d]", step, len(loader))
total = sum(box_counts.values())
write_data_yaml(out_dir / "data.yaml", classes, root=out_dir)
summary = {
"checkpoint": args.checkpoint,
"dataset": str(data_cfg["root"]),
"num_images": total,
"cam_shape": list(cam_shape) if cam_shape else None,
"stage": args.stage,
"threshold": args.threshold,
"padding": args.padding,
"max_boxes": args.max_boxes,
"min_area_ratio": args.min_area_ratio,
"peak_ratio": args.peak_ratio,
"classification_accuracy_on_all": correct / total if total else 0.0,
"boxes_per_image": {str(k): v for k, v in sorted(box_counts.items())},
"boxes_per_class": dict(sorted(per_class_boxes.items())),
}
(out_dir / "summary.json").write_text(
json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8"
)
logger.info("CAM 分辨率 %s | 全量分类准确率 %.4f", cam_shape, summary["classification_accuracy_on_all"])
for count, number in sorted(box_counts.items()):
logger.info(" 生成 %d 个框的图片: %d 张 (%.1f%%)", count, number, 100 * number / total)
logger.info("标签写入 %s,可视化 %d 张写入 %s", labels_dir, sum(viz_done.values()), viz_dir)
return 0
if __name__ == "__main__":
raise SystemExit(main())