feat(localization): SHAP Partition 定位方法,并把每图框数上限默认收紧为 1
新增基于 SHAP Partition explainer 的定位,与 Grad-CAM 形成互补对照: - engine/shap_explain.py:对目标类别求 SHAP 归因(只保留正贡献),以及方框平滑。 遮挡用数值 masker 而非教程里的 inpaint_telea——后者依赖 opencv,而我们的图已做过 逐图标准化,用 0(即该图均值)遮挡语义更干净 - scripts/shap_boxes.py:按类别抽样生成标签,并输出「原图 | Grad-CAM | SHAP」并排图 - shap 放入可选依赖组:uv sync --group explain 实测(225 张,每类 15):SHAP 在小目标类别上明显更准,框面积占比 blackdot 0.019 vs 0.269(14 倍)、mosquito 0.030 vs 0.173、whitedot 0.076 vs 0.253。 原因是 Grad-CAM 受限于 8x8 特征图(每格 16x16 像素),几像素的黑点根本不可分辨。 代价是单张 0.55 秒,全量 11k 张约 1.7 小时,而 Grad-CAM 全量只要 2 分钟。 调参时发现的坑:SHAP 第一版全部出 0 框,因为沿用了给 8x8 调的 min_area_ratio=0.02(在 128x128 上等于 328 像素)。参数不能跨分辨率照搬, SHAP 默认改为 0.002。 --max-boxes 两个脚本都默认改为 1(原为 2):一张裁片通常只对应一处缺陷。 库函数 cam_to_boxes 的默认值同步收紧,相关测试改为显式传参而不依赖默认值。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
fc7136fddf
commit
9aa87d31a6
@@ -667,8 +667,10 @@ resnet18 在 128×128 输入下各层的 CAM 尺寸:layer4 只有 4×4、layer
|
|||||||
### 几个调参点
|
### 几个调参点
|
||||||
|
|
||||||
- `--threshold`(默认 0.4):热力图相对最大值的阈值
|
- `--threshold`(默认 0.4):热力图相对最大值的阈值
|
||||||
|
- `--max-boxes`(默认 1):每张图最多保留几个框,按连通域内热力总和排序取前 N 个。
|
||||||
|
一张裁片通常只对应一处缺陷,多出来的框多半是噪声;确实有多缺陷的图再调大
|
||||||
- `--peak-ratio`(默认 0.75):次要连通域的峰值要达到全局最大值的这个比例才保留。
|
- `--peak-ratio`(默认 0.75):次要连通域的峰值要达到全局最大值的这个比例才保留。
|
||||||
不加这条会在没有缺陷的角落刷出假框——实测把两框比例从 53% 降到 20%
|
不加这条会在没有缺陷的角落刷出假框
|
||||||
- `--padding`(默认 0.1):框四周外扩。CAM 倾向只覆盖最具判别性的部位(缺陷中心),
|
- `--padding`(默认 0.1):框四周外扩。CAM 倾向只覆盖最具判别性的部位(缺陷中心),
|
||||||
往往比真实缺陷范围小
|
往往比真实缺陷范围小
|
||||||
- `--viz-per-class`(默认 20):每类存多少张可视化;`--viz-all` 全存(很慢很占盘)
|
- `--viz-per-class`(默认 20):每类存多少张可视化;`--viz-all` 全存(很慢很占盘)
|
||||||
|
|||||||
+2
-2
@@ -108,7 +108,7 @@ def cam_to_boxes(
|
|||||||
cam: np.ndarray,
|
cam: np.ndarray,
|
||||||
threshold: float = 0.4,
|
threshold: float = 0.4,
|
||||||
min_area_ratio: float = 0.01,
|
min_area_ratio: float = 0.01,
|
||||||
max_boxes: int = 3,
|
max_boxes: int = 1,
|
||||||
padding: float = 0.0,
|
padding: float = 0.0,
|
||||||
peak_ratio: float = 0.75,
|
peak_ratio: float = 0.75,
|
||||||
) -> list[tuple[float, float, float, float]]:
|
) -> list[tuple[float, float, float, float]]:
|
||||||
@@ -117,7 +117,7 @@ def cam_to_boxes(
|
|||||||
Args:
|
Args:
|
||||||
threshold: 相对最大值的阈值;CAM 已归一化到 [0,1],所以直接比较。
|
threshold: 相对最大值的阈值;CAM 已归一化到 [0,1],所以直接比较。
|
||||||
min_area_ratio: 连通域面积小于整图这个比例就丢掉,滤掉零星的高响应点。
|
min_area_ratio: 连通域面积小于整图这个比例就丢掉,滤掉零星的高响应点。
|
||||||
max_boxes: 最多保留几个框(按连通域内热力总和排序)。
|
max_boxes: 最多保留几个框(按连通域内热力总和排序)。默认 1——一张裁片通常只对应一处缺陷。
|
||||||
padding: 框的四周外扩比例——CAM 倾向只覆盖最具判别性的部位(比如缺陷中心),
|
padding: 框的四周外扩比例——CAM 倾向只覆盖最具判别性的部位(比如缺陷中心),
|
||||||
往往比真实缺陷范围小,适当外扩更贴合。
|
往往比真实缺陷范围小,适当外扩更贴合。
|
||||||
peak_ratio: 连通域的峰值至少要达到全局最大值的这个比例才保留。
|
peak_ratio: 连通域的峰值至少要达到全局最大值的这个比例才保留。
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""用 SHAP 的 Partition explainer 做定位归因。
|
||||||
|
|
||||||
|
与 Grad-CAM 的取舍正好互补:
|
||||||
|
|
||||||
|
- **分辨率**:Grad-CAM 受限于卷积特征图(resnet18 @128² 的 layer3 只有 8×8),
|
||||||
|
SHAP 直接在像素上做层次划分,输出与输入同分辨率。
|
||||||
|
- **代价**:Grad-CAM 一次 backward 就够;Partition explainer 要几百次前向,
|
||||||
|
单张约 1 秒,全量 11k 张需要数小时。
|
||||||
|
|
||||||
|
原理:把图像层次化地切块,用 Owen 值近似 Shapley 值,衡量每块对目标类别分数的贡献。
|
||||||
|
遮挡用数值 masker(默认 0)而不是教程里的 inpaint——我们的图已做过逐图标准化,
|
||||||
|
0 恰好是该图均值,语义上就是"抹掉这块的信息",而且不必依赖 opencv。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
from torch import nn
|
||||||
|
|
||||||
|
|
||||||
|
def shap_attribution(
|
||||||
|
model: nn.Module,
|
||||||
|
image: np.ndarray,
|
||||||
|
target: int,
|
||||||
|
device: torch.device,
|
||||||
|
max_evals: int = 500,
|
||||||
|
batch_size: int = 64,
|
||||||
|
mask_value: float = 0.0,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""对单张图求目标类别的 SHAP 归因,返回与输入同尺寸的 (H, W) 图,已归一化到 [0, 1]。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image: HWC 的 float 数组,取值范围与训练时一致(这里是逐图标准化之后的)。
|
||||||
|
target: 要解释的类别下标——用图片所在目录的真实类别,而不是模型预测。
|
||||||
|
|
||||||
|
只保留**正贡献**:负贡献表示"这块像素在把分数往下拉",对定位没有意义。
|
||||||
|
"""
|
||||||
|
import shap
|
||||||
|
|
||||||
|
def predict(batch: np.ndarray) -> np.ndarray:
|
||||||
|
tensor = torch.from_numpy(batch).permute(0, 3, 1, 2).float().to(device)
|
||||||
|
with torch.no_grad():
|
||||||
|
# 只输出目标类别这一列,SHAP 就退化成对单个标量的归因,省掉其余类别的计算
|
||||||
|
return model(tensor)[:, target : target + 1].cpu().numpy()
|
||||||
|
|
||||||
|
masker = shap.maskers.Image(mask_value, image.shape)
|
||||||
|
explainer = shap.Explainer(predict, masker)
|
||||||
|
explanation = explainer(image[None], max_evals=max_evals, batch_size=batch_size, silent=True)
|
||||||
|
|
||||||
|
values = np.asarray(explanation.values[0])
|
||||||
|
if values.ndim == 4: # (H, W, C, n_outputs)
|
||||||
|
values = values[..., 0]
|
||||||
|
attribution = values.sum(axis=-1) if values.ndim == 3 else values
|
||||||
|
|
||||||
|
positive = np.clip(attribution, 0.0, None)
|
||||||
|
peak = positive.max()
|
||||||
|
return positive / peak if peak > 0 else positive
|
||||||
|
|
||||||
|
|
||||||
|
def smooth(attribution: np.ndarray, radius: int = 2) -> np.ndarray:
|
||||||
|
"""方框平滑。像素级归因偏碎,直接取连通域会切出很多小块;
|
||||||
|
轻微平滑后再阈值化,框更完整。radius=0 表示不平滑。"""
|
||||||
|
if radius <= 0:
|
||||||
|
return attribution
|
||||||
|
size = 2 * radius + 1
|
||||||
|
padded = np.pad(attribution, radius, mode="edge")
|
||||||
|
# 用积分图做 O(1) 的方框均值,避免逐像素卷积
|
||||||
|
integral = padded.cumsum(axis=0).cumsum(axis=1)
|
||||||
|
integral = np.pad(integral, ((1, 0), (1, 0)), mode="constant")
|
||||||
|
height, width = attribution.shape
|
||||||
|
total = (
|
||||||
|
integral[size:size + height, size:size + width]
|
||||||
|
- integral[0:height, size:size + width]
|
||||||
|
- integral[size:size + height, 0:width]
|
||||||
|
+ integral[0:height, 0:width]
|
||||||
|
)
|
||||||
|
smoothed = total / (size * size)
|
||||||
|
peak = smoothed.max()
|
||||||
|
return smoothed / peak if peak > 0 else smoothed
|
||||||
|
|
||||||
|
|
||||||
|
def batched_attribution(
|
||||||
|
model: nn.Module,
|
||||||
|
images: np.ndarray,
|
||||||
|
targets: list[int],
|
||||||
|
device: torch.device,
|
||||||
|
progress: Callable[[int], None] | None = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> list[np.ndarray]:
|
||||||
|
"""逐张求归因。Partition explainer 本身是按单图工作的,这里只是循环 + 进度回调。"""
|
||||||
|
results = []
|
||||||
|
for index, (image, target) in enumerate(zip(images, targets, strict=True)):
|
||||||
|
results.append(shap_attribution(model, image, target, device, **kwargs))
|
||||||
|
if progress:
|
||||||
|
progress(index)
|
||||||
|
return results
|
||||||
@@ -27,6 +27,10 @@ export = [
|
|||||||
"onnx>=1.16",
|
"onnx>=1.16",
|
||||||
"onnxscript>=0.2",
|
"onnxscript>=0.2",
|
||||||
]
|
]
|
||||||
|
# SHAP 归因定位(scripts/shap_boxes.py),按需安装:uv sync --group explain
|
||||||
|
explain = [
|
||||||
|
"shap>=0.48",
|
||||||
|
]
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
# 本仓库是应用而非库:不打包安装,直接从仓库根目录运行 train.py / eval.py。
|
# 本仓库是应用而非库:不打包安装,直接从仓库根目录运行 train.py / eval.py。
|
||||||
|
|||||||
@@ -105,7 +105,11 @@ def main() -> int:
|
|||||||
)
|
)
|
||||||
parser.add_argument("--threshold", type=float, default=0.4, help="热力图阈值(相对最大值)")
|
parser.add_argument("--threshold", type=float, default=0.4, help="热力图阈值(相对最大值)")
|
||||||
parser.add_argument("--padding", type=float, default=0.1, help="框四周外扩比例")
|
parser.add_argument("--padding", type=float, default=0.1, help="框四周外扩比例")
|
||||||
parser.add_argument("--max-boxes", type=int, default=2, help="每张图最多几个框")
|
parser.add_argument(
|
||||||
|
"--max-boxes", type=int, default=1,
|
||||||
|
help="每张图最多保留几个框(按连通域内热力总和排序取前 N 个)。"
|
||||||
|
"默认 1:一张裁片通常只对应一处缺陷,多出来的框多半是噪声",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--min-area-ratio", type=float, default=0.02, help="连通域面积下限(占整图比例)"
|
"--min-area-ratio", type=float, default=0.02, help="连通域面积下限(占整图比例)"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""用 SHAP Partition explainer 生成框标签,并与 Grad-CAM 并排对比。
|
||||||
|
|
||||||
|
uv run python scripts/shap_boxes.py --checkpoint runs/classify/best.pth --per-class 10
|
||||||
|
|
||||||
|
与 Grad-CAM 的取舍:
|
||||||
|
|
||||||
|
| | Grad-CAM | SHAP Partition |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 分辨率 | 受限于特征图(layer3 @128² 只有 8×8) | 与输入同分辨率(128×128) |
|
||||||
|
| 单张耗时 | 一次 backward,全量 11k 张约 2 分钟 | 几百次前向,单张约 1 秒 |
|
||||||
|
|
||||||
|
所以 SHAP 默认**按类别抽样**跑(`--per-class`),用来判断值不值得为它付出时间;
|
||||||
|
确实要全量请加 `--all`,注意 11k 张需要数小时。
|
||||||
|
|
||||||
|
产出与 gradcam_boxes.py 同构:labels/ 是 YOLO 标签,viz/ 是对比图
|
||||||
|
(原图 | Grad-CAM 热力+框 | SHAP 热力+框),一眼看出谁定得更准。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
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 datasets import FolderClassificationDataset # 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 engine.shap_explain import shap_attribution, smooth # 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_comparison(path: Path, image_path: Path, panels: list[tuple[str, np.ndarray, list]]) -> 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]
|
||||||
|
|
||||||
|
fig, axes = plt.subplots(1, len(panels) + 1, figsize=(4 * (len(panels) + 1), 4))
|
||||||
|
axes[0].imshow(image)
|
||||||
|
axes[0].set_title("original")
|
||||||
|
axes[0].axis("off")
|
||||||
|
|
||||||
|
for ax, (title, heat, boxes) in zip(axes[1:], panels, strict=True):
|
||||||
|
resized = np.asarray(
|
||||||
|
Image.fromarray((heat * 255).astype(np.uint8)).resize((width, height), Image.BILINEAR)
|
||||||
|
) / 255.0
|
||||||
|
ax.imshow(image)
|
||||||
|
ax.imshow(resized, cmap="jet", alpha=0.45, vmin=0, vmax=1)
|
||||||
|
for x1, y1, x2, y2 in boxes:
|
||||||
|
ax.add_patch(
|
||||||
|
Rectangle((x1 * width, y1 * height), (x2 - x1) * width, (y2 - y1) * height,
|
||||||
|
fill=False, edgecolor="lime", linewidth=2)
|
||||||
|
)
|
||||||
|
ax.set_title(f"{title} ({heat.shape[0]}x{heat.shape[1]}, {len(boxes)} box)")
|
||||||
|
ax.axis("off")
|
||||||
|
|
||||||
|
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")
|
||||||
|
parser.add_argument("--config", default="configs/classify/film.yaml")
|
||||||
|
parser.add_argument("--out", default="runs/shap_boxes")
|
||||||
|
parser.add_argument("--set", nargs="*", default=[], metavar="KEY=VALUE")
|
||||||
|
parser.add_argument("--device", default=None)
|
||||||
|
parser.add_argument("--per-class", type=int, default=10, help="每个类别抽多少张")
|
||||||
|
parser.add_argument("--all", action="store_true", help="跑全量(11k 张需数小时)")
|
||||||
|
parser.add_argument("--seed", type=int, default=0, help="抽样随机种子")
|
||||||
|
parser.add_argument("--max-evals", type=int, default=500, help="SHAP 每张图的前向次数")
|
||||||
|
parser.add_argument("--smooth-radius", type=int, default=2, help="归因图方框平滑半径,0=不平滑")
|
||||||
|
parser.add_argument("--threshold", type=float, default=0.4)
|
||||||
|
parser.add_argument("--padding", type=float, default=0.1)
|
||||||
|
parser.add_argument(
|
||||||
|
"--max-boxes", type=int, default=1,
|
||||||
|
help="每张图最多保留几个框(按归因总量排序取前 N 个)。"
|
||||||
|
"默认 1:一张裁片通常只对应一处缺陷,多出来的框多半是噪声",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--min-area-ratio", type=float, default=0.002,
|
||||||
|
help="连通域面积下限(占整图比例)。默认值远小于 gradcam_boxes.py 的 0.02——"
|
||||||
|
"SHAP 的归因图是 128x128 而不是 8x8,同样一个小黑点在这里只占百来像素,"
|
||||||
|
"沿用 0.02(=328 像素)会把它整个滤掉",
|
||||||
|
)
|
||||||
|
parser.add_argument("--peak-ratio", type=float, default=0.75)
|
||||||
|
parser.add_argument("--stage", type=int, default=6, help="对比用的 Grad-CAM 目标层")
|
||||||
|
parser.add_argument("--no-compare", action="store_true", help="不算 Grad-CAM,只出 SHAP")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
out_dir = Path(args.out)
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
logger = setup_logging(out_dir / "shap.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 [],
|
||||||
|
)
|
||||||
|
classes = dataset.classes
|
||||||
|
|
||||||
|
if not args.all:
|
||||||
|
by_class: dict[int, list] = {}
|
||||||
|
for sample in dataset.samples:
|
||||||
|
by_class.setdefault(sample[1], []).append(sample)
|
||||||
|
rng = random.Random(args.seed)
|
||||||
|
dataset.samples = [
|
||||||
|
sample
|
||||||
|
for samples in by_class.values()
|
||||||
|
for sample in rng.sample(samples, min(args.per_class, len(samples)))
|
||||||
|
]
|
||||||
|
|
||||||
|
model_cfg = dict(cfg["model"])
|
||||||
|
model_cfg.setdefault("num_classes", len(classes))
|
||||||
|
model_cfg["init_from"] = None
|
||||||
|
model = build_model(model_cfg)
|
||||||
|
load_checkpoint(args.checkpoint, model, map_location=device)
|
||||||
|
model.to(device).eval()
|
||||||
|
for param in model.parameters():
|
||||||
|
param.requires_grad_(True)
|
||||||
|
|
||||||
|
logger.info("设备: %s | 权重: %s", describe_device(device), args.checkpoint)
|
||||||
|
logger.info(
|
||||||
|
"样本 %d 张(%s)| max_evals=%d | 平滑半径 %d",
|
||||||
|
len(dataset), "全量" if args.all else f"每类 {args.per_class} 张",
|
||||||
|
args.max_evals, args.smooth_radius,
|
||||||
|
)
|
||||||
|
|
||||||
|
box_kwargs = {
|
||||||
|
"threshold": args.threshold, "min_area_ratio": args.min_area_ratio,
|
||||||
|
"max_boxes": args.max_boxes, "padding": args.padding, "peak_ratio": args.peak_ratio,
|
||||||
|
}
|
||||||
|
labels_dir = out_dir / "labels"
|
||||||
|
viz_dir = out_dir / "viz"
|
||||||
|
shap_counts: Counter[int] = Counter()
|
||||||
|
cam_counts: Counter[int] = Counter()
|
||||||
|
started = time.time()
|
||||||
|
|
||||||
|
for index in range(len(dataset)):
|
||||||
|
sample = dataset[index]
|
||||||
|
target = sample["label"]
|
||||||
|
name = classes[target]
|
||||||
|
source = Path(sample["meta"]["path"])
|
||||||
|
image_chw = sample["image"]
|
||||||
|
|
||||||
|
attribution = shap_attribution(
|
||||||
|
model, image_chw.permute(1, 2, 0).numpy().astype(np.float32), target, device,
|
||||||
|
max_evals=args.max_evals,
|
||||||
|
)
|
||||||
|
attribution = smooth(attribution, args.smooth_radius)
|
||||||
|
shap_boxes = cam_to_boxes(attribution, **box_kwargs)
|
||||||
|
shap_counts[len(shap_boxes)] += 1
|
||||||
|
|
||||||
|
panels = []
|
||||||
|
if not args.no_compare:
|
||||||
|
cam = grad_cam(
|
||||||
|
model.backbone, model.head, image_chw[None].to(device),
|
||||||
|
torch.tensor([target], device=device), stage=args.stage,
|
||||||
|
)[0].cpu().numpy()
|
||||||
|
cam_boxes = cam_to_boxes(cam, **box_kwargs)
|
||||||
|
cam_counts[len(cam_boxes)] += 1
|
||||||
|
panels.append(("grad-cam", cam, cam_boxes))
|
||||||
|
panels.append(("shap", attribution, shap_boxes))
|
||||||
|
|
||||||
|
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 shap_boxes) + ("\n" if shap_boxes else ""),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
save_comparison(viz_dir / name / f"{source.stem}.png", source, panels)
|
||||||
|
|
||||||
|
if index % 20 == 0:
|
||||||
|
elapsed = time.time() - started
|
||||||
|
logger.info(
|
||||||
|
"[%d/%d] 已用 %.0fs,平均 %.2fs/张",
|
||||||
|
index, len(dataset), elapsed, elapsed / max(index, 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
total = sum(shap_counts.values())
|
||||||
|
write_data_yaml(out_dir / "data.yaml", classes, root=out_dir)
|
||||||
|
summary = {
|
||||||
|
"checkpoint": args.checkpoint,
|
||||||
|
"num_images": total,
|
||||||
|
"seconds_per_image": (time.time() - started) / max(total, 1),
|
||||||
|
"max_evals": args.max_evals,
|
||||||
|
"smooth_radius": args.smooth_radius,
|
||||||
|
"box_params": box_kwargs,
|
||||||
|
"shap_boxes_per_image": {str(k): v for k, v in sorted(shap_counts.items())},
|
||||||
|
"gradcam_boxes_per_image": {str(k): v for k, v in sorted(cam_counts.items())},
|
||||||
|
}
|
||||||
|
(out_dir / "summary.json").write_text(
|
||||||
|
json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("共 %d 张,平均 %.2fs/张", total, summary["seconds_per_image"])
|
||||||
|
logger.info("SHAP 出框分布: %s", dict(sorted(shap_counts.items())))
|
||||||
|
if cam_counts:
|
||||||
|
logger.info("Grad-CAM 出框分布: %s", dict(sorted(cam_counts.items())))
|
||||||
|
logger.info("对比图写入 %s", viz_dir)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
+15
-3
@@ -61,15 +61,27 @@ def test_peak_ratio_drops_weak_secondary_blobs():
|
|||||||
cam[1:3, 1:3] = 1.0 # 主区域
|
cam[1:3, 1:3] = 1.0 # 主区域
|
||||||
cam[5:7, 5:7] = 0.45 # 弱响应区
|
cam[5:7, 5:7] = 0.45 # 弱响应区
|
||||||
|
|
||||||
assert len(cam_to_boxes(cam, threshold=0.4, min_area_ratio=0.0, peak_ratio=0.0)) == 2
|
kwargs = {"threshold": 0.4, "min_area_ratio": 0.0, "max_boxes": 5} # 显式放开数量上限
|
||||||
assert len(cam_to_boxes(cam, threshold=0.4, min_area_ratio=0.0, peak_ratio=0.75)) == 1
|
assert len(cam_to_boxes(cam, peak_ratio=0.0, **kwargs)) == 2
|
||||||
|
assert len(cam_to_boxes(cam, peak_ratio=0.75, **kwargs)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_max_boxes_defaults_to_one():
|
||||||
|
"""默认只留最强的一个框——一张裁片通常只对应一处缺陷。"""
|
||||||
|
cam = np.zeros((12, 12), dtype=np.float32)
|
||||||
|
cam[1:4, 1:4] = 1.0
|
||||||
|
cam[7:10, 7:10] = 0.95 # 峰值够高,不会被 peak_ratio 滤掉
|
||||||
|
|
||||||
|
assert len(cam_to_boxes(cam, threshold=0.4, min_area_ratio=0.0)) == 1
|
||||||
|
assert len(cam_to_boxes(cam, threshold=0.4, min_area_ratio=0.0, max_boxes=2)) == 2
|
||||||
|
|
||||||
|
|
||||||
def test_min_area_and_max_boxes_limits():
|
def test_min_area_and_max_boxes_limits():
|
||||||
cam = np.zeros((10, 10), dtype=np.float32)
|
cam = np.zeros((10, 10), dtype=np.float32)
|
||||||
cam[0:1, 0:1] = 1.0 # 1 格,面积很小
|
cam[0:1, 0:1] = 1.0 # 1 格,面积很小
|
||||||
cam[4:8, 4:8] = 1.0
|
cam[4:8, 4:8] = 1.0
|
||||||
assert len(cam_to_boxes(cam, threshold=0.5, min_area_ratio=0.05, peak_ratio=0.5)) == 1
|
assert len(cam_to_boxes(cam, threshold=0.5, min_area_ratio=0.05, peak_ratio=0.5,
|
||||||
|
max_boxes=5)) == 1
|
||||||
|
|
||||||
many = np.zeros((12, 12), dtype=np.float32)
|
many = np.zeros((12, 12), dtype=np.float32)
|
||||||
for offset in (0, 4, 8):
|
for offset in (0, 4, 8):
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""SHAP 归因定位。"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
torch = pytest.importorskip("torch")
|
||||||
|
pytest.importorskip("shap")
|
||||||
|
|
||||||
|
import numpy as np # noqa: E402
|
||||||
|
from torch import nn # noqa: E402
|
||||||
|
|
||||||
|
from engine.gradcam import cam_to_boxes # noqa: E402
|
||||||
|
from engine.shap_explain import shap_attribution, smooth # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class _Detector(nn.Module):
|
||||||
|
"""把左上角亮度当成第 1 类的证据——归因应当集中在那里。"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.bias = nn.Parameter(torch.zeros(2), requires_grad=False)
|
||||||
|
|
||||||
|
def forward(self, x):
|
||||||
|
corner = x[:, :, :8, :8].mean(dim=(1, 2, 3))
|
||||||
|
return torch.stack([-corner, corner], dim=1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_smooth_spreads_and_renormalizes():
|
||||||
|
peak = np.zeros((16, 16), dtype=np.float32)
|
||||||
|
peak[8, 8] = 1.0
|
||||||
|
|
||||||
|
smoothed = smooth(peak, radius=2)
|
||||||
|
assert smoothed.shape == peak.shape
|
||||||
|
assert int((smoothed > 0).sum()) == 25 # 5x5 方框
|
||||||
|
assert float(smoothed.max()) == pytest.approx(1.0) # 重新归一化到 1
|
||||||
|
assert np.allclose(smooth(peak, radius=0), peak)
|
||||||
|
|
||||||
|
|
||||||
|
def test_smooth_keeps_flat_map_flat():
|
||||||
|
flat = np.full((8, 8), 0.5, dtype=np.float32)
|
||||||
|
assert np.allclose(smooth(flat, radius=2), 1.0) # 归一化后整体为 1,不产生伪峰
|
||||||
|
|
||||||
|
|
||||||
|
def test_attribution_lands_on_the_evidence():
|
||||||
|
"""模型只看左上角,SHAP 的正贡献就该集中在左上角。"""
|
||||||
|
model = _Detector().eval()
|
||||||
|
image = np.zeros((16, 16, 3), dtype=np.float32)
|
||||||
|
image[:8, :8] = 1.0
|
||||||
|
|
||||||
|
attribution = shap_attribution(
|
||||||
|
model, image, target=1, device=torch.device("cpu"), max_evals=200, batch_size=32
|
||||||
|
)
|
||||||
|
assert attribution.shape == (16, 16) # 与输入同分辨率,不是特征图分辨率
|
||||||
|
assert 0.0 <= attribution.min() and attribution.max() == pytest.approx(1.0)
|
||||||
|
assert attribution[:8, :8].mean() > attribution[8:, 8:].mean()
|
||||||
|
|
||||||
|
|
||||||
|
def test_attribution_is_non_negative():
|
||||||
|
"""只保留正贡献:负贡献表示"这块在把分数往下拉",对定位没有意义。"""
|
||||||
|
model = _Detector().eval()
|
||||||
|
image = np.random.default_rng(0).random((16, 16, 3)).astype(np.float32)
|
||||||
|
attribution = shap_attribution(
|
||||||
|
model, image, target=0, device=torch.device("cpu"), max_evals=200, batch_size=32
|
||||||
|
)
|
||||||
|
assert (attribution >= 0).all()
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_resolution_beats_feature_map_for_small_targets():
|
||||||
|
"""SHAP 与输入同分辨率,小目标不会被格子吞掉——这是它相对 Grad-CAM 的核心优势。
|
||||||
|
|
||||||
|
一个 10x10 的小目标在 128x128 归因图上占 100 像素:
|
||||||
|
按 gradcam 那套 min_area_ratio=0.02(=328 像素)会被整个滤掉,
|
||||||
|
换成 0.002(=33 像素)才留得住——所以 SHAP 必须用小得多的阈值。
|
||||||
|
"""
|
||||||
|
attribution = np.zeros((128, 128), dtype=np.float32)
|
||||||
|
attribution[60:70, 60:70] = 1.0
|
||||||
|
|
||||||
|
assert cam_to_boxes(attribution, threshold=0.4, min_area_ratio=0.02) == []
|
||||||
|
(box,) = cam_to_boxes(attribution, threshold=0.4, min_area_ratio=0.002)
|
||||||
|
x1, y1, x2, y2 = box
|
||||||
|
assert (x2 - x1) < 0.15 and (y2 - y1) < 0.15 # 框紧贴小目标
|
||||||
Reference in New Issue
Block a user