背景差分:大半径方框均值估背景 → 残差(可选极性)→ 平滑 → 有无判定 → 按峰值归一化, 再交给与 gradcam / shap 同一套 cam_to_boxes 出框。只依赖 numpy。 与归因方法的差别:不需要 --checkpoint,分类器还没训练出来就能先跑一版框,也不受分类 精度影响;类别只能来自目录(--target label);找的是偏离局部背景的区域,而不是模型 据以判断类别的区域,所以对局部目标好、对铺满整图的纹理类给不出有意义的框。 有无缺陷是显式判定的:残差峰值要同时高于绝对对比度下限与噪声水平(MAD 估计)的 15 倍, 否则返回全零图。这条是必需的——实测纯噪声图的"峰值/MAD"稳定在 9 附近(最大 10.9, 几乎与噪声大小无关),有 0.25 对比度黑点时是 25~330;若只按峰值归一化,正常图的噪声 会被拉到 1,满图假框。阈值 15 取在两者之间。 顺带把积分图方框均值从 shap_explain.smooth 提到 engine/classical.box_mean 共用, 避免两处实现各自漂移。 实测:单张 0.011 秒(全量 11k 约 2 分钟);真实薄膜图上框能套住黑点、虫状物与划痕。 已知局限:背景估计在图像边界处对明暗阶跃有响应,贴边的交界可能出假框。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
91 lines
3.5 KiB
Python
91 lines
3.5 KiB
Python
"""用 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
|
||
# 与 engine/classical.py 共用同一份积分图实现,免得两处各自漂移
|
||
from engine.classical import box_mean
|
||
|
||
smoothed = box_mean(attribution, radius)
|
||
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
|