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:
Shaun Zhou
2026-09-10 16:10:50 +08:00
co-authored by Claude Opus 5
parent fc7136fddf
commit 9aa87d31a6
9 changed files with 2173 additions and 1344 deletions
+15 -3
View File
@@ -61,15 +61,27 @@ def test_peak_ratio_drops_weak_secondary_blobs():
cam[1:3, 1:3] = 1.0 # 主区域
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
assert len(cam_to_boxes(cam, threshold=0.4, min_area_ratio=0.0, peak_ratio=0.75)) == 1
kwargs = {"threshold": 0.4, "min_area_ratio": 0.0, "max_boxes": 5} # 显式放开数量上限
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():
cam = np.zeros((10, 10), dtype=np.float32)
cam[0:1, 0:1] = 1.0 # 1 格,面积很小
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)
for offset in (0, 4, 8):