feat(boxes): 新增传统 CV 定位方法(--method cv),不依赖分类模型

背景差分:大半径方框均值估背景 → 残差(可选极性)→ 平滑 → 有无判定 → 按峰值归一化,
再交给与 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>
This commit is contained in:
Shaun Zhou
2026-09-17 11:03:13 +08:00
co-authored by Claude Opus 5
parent 32e56cb230
commit 65bc6b2045
6 changed files with 494 additions and 46 deletions
+98 -23
View File
@@ -7,13 +7,22 @@
# SHAP Partition(慢但更精细,全量约 1.7 小时)
uv run python scripts/generate_boxes.py --out runs/boxes_shap --method shap
两种归因方法都能定位,取舍不同:
# 传统 CV:背景差分,不需要分类模型
uv run python scripts/generate_boxes.py --out runs/boxes_cv --method cv
| | gradcam | shap |
| --- | --- | --- |
| 分辨率 | 受限于特征图(layer3 @128² 只有 8×8) | 与输入同分辨率(128×128) |
| 单张耗时 | 一次 backward,约 0.01 秒 | 几百次前向,约 0.55 秒 |
| 小目标 | 几像素的黑点在 8×8 上不可分辨 | 能贴合到几十像素的目标 |
三种方法都能定位,取舍不同:
| | gradcam | shap | cv |
| --- | --- | --- | --- |
| 依赖分类模型 | 是 | 是 | **否** |
| 分辨率 | 受限于特征图(layer3 @128² 只有 8×8) | 与输入同分辨率(128×128) | 原图分辨率 |
| 单张耗时 | 一次 backward,约 0.01 秒 | 几百次前向,约 0.55 秒 | 几次方框滤波,约 0.005 秒 |
| 小目标 | 几像素的黑点在 8×8 上不可分辨 | 能贴合到几十像素的目标 | 贴合好,但纹理噪声也会被找出来 |
| 找的是什么 | 模型据以判断类别的区域 | 同左,更精细 | 偏离局部背景的区域 |
cv 不判类别,只定位,所以它只能配 `--target label`(默认),且**不需要 --checkpoint**:
分类器还没训练时就能先跑一版框出来看。它对 blackdot / whitedot / mosquito 这类局部目标
效果好;ripple / wrinkle 这种铺满整图的纹理类,本来就没有局部目标,它给不出有意义的框。
产出是一个**自成一体的 YOLO 数据集**,可直接喂给 train.py:
@@ -65,12 +74,16 @@ from utils.device import DeviceNotAvailableError, describe_device, resolve_devic
logger = None
METHODS = ("gradcam", "shap")
METHODS = ("gradcam", "shap", "cv")
#: 两种方法的归因图分辨率差 16 倍,面积阈值不能共用一个默认值:
#: 各方法的归因图分辨率差着数量级,面积阈值不能共用一个默认值:
#: 同一个小黑点在 8×8 上占几格、在 128×128 上只占百来像素,
#: 沿用 0.02(=328 像素)会把它整个滤掉。
DEFAULT_MIN_AREA_RATIO = {"gradcam": 0.02, "shap": 0.002}
#: 沿用 0.02(=328 像素)会把它整个滤掉。cv 在原图分辨率上跑,与 shap 同量级。
DEFAULT_MIN_AREA_RATIO = {"gradcam": 0.02, "shap": 0.002, "cv": 0.002}
#: cv 的分析分辨率上限。框坐标是归一化的,缩小不影响标签,却能挡住
#: connected_components 那个纯 Python 的 BFS 在大图上变慢(数据里最大有 700×712)。
CV_MAX_SIDE = 256
class NameCollision(RuntimeError):
@@ -161,7 +174,27 @@ def parse_args() -> argparse.Namespace:
"tiny_conv 这类不是 nn.Sequential 的骨架只能这样取",
)
parser.add_argument("--max-evals", type=int, default=500, help="shap 每张图的前向次数")
parser.add_argument("--smooth-radius", type=int, default=2, help="shap 归因图平滑半径,0=不平滑")
parser.add_argument(
"--smooth-radius", type=int, default=2, help="shap / cv 归因图平滑半径,0=不平滑",
)
parser.add_argument(
"--cv-radius", type=int, default=0,
help="cv 的背景估计半径,0=按图取 min(高,宽)//8。太小会把缺陷本身算进背景",
)
parser.add_argument(
"--cv-polarity", choices=("dark", "bright", "both"), default="both",
help="cv 找哪种缺陷:dark=比背景暗(黑点/蚊虫),bright=更亮(白点/反光),both=两者",
)
parser.add_argument(
"--cv-min-contrast", type=float, default=0.05,
help="cv 的绝对对比度下限(0~1 灰度,0.05≈13 个灰阶)。"
"残差峰值低于它就判定整图无缺陷,出 0 个框",
)
parser.add_argument(
"--cv-max-side", type=int, default=CV_MAX_SIDE,
help="cv 分析前把长边缩到这个上限(框是归一化的,不影响标签),0=不缩",
)
parser.add_argument("--no-heatmaps", action="store_true", help="不输出热力图,只出标签")
parser.add_argument("--no-images", action="store_true", help="不建原图硬链接")
@@ -205,17 +238,22 @@ def main() -> int:
if args.limit:
dataset.samples = dataset.samples[: args.limit]
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(): # 归因需要梯度,但不更新权重
param.requires_grad_(True)
# cv 不判类别也不做归因;只有需要模型预测时才加载权重,
# 这样分类器还没训练出来也能先跑一版 cv 框
needs_model = args.method != "cv" or args.target == "predicted"
model = None
if needs_model:
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(): # 归因需要梯度,但不更新权重
param.requires_grad_(True)
stage = args.stage if args.stage >= 0 else None
if stage is not None and not isinstance(
if args.method == "gradcam" and stage is not None and not isinstance(
getattr(model.backbone, "features", None), torch.nn.Sequential
):
# tiny_conv 这类骨架没有 nn.Sequential 的 features,取不了中间层;
@@ -235,11 +273,20 @@ def main() -> int:
"threshold": args.threshold, "min_area_ratio": min_area_ratio,
"max_boxes": args.max_boxes, "padding": args.padding, "peak_ratio": args.peak_ratio,
}
cv_kwargs = {
"background_radius": args.cv_radius, "polarity": args.cv_polarity,
"smooth_radius": args.smooth_radius, "min_contrast": args.cv_min_contrast,
}
logger.info("设备: %s | 权重: %s", describe_device(device), args.checkpoint)
logger.info(
"设备: %s | 权重: %s", describe_device(device),
args.checkpoint if needs_model else "不需要(cv + --target label)",
)
logger.info("方法: %s | 目标类别: %s", args.method, args.target)
logger.info("数据集 %s:%d 张,%d 类", data_cfg["root"], len(dataset), len(classes))
logger.info("框参数: %s", box_kwargs)
if args.method == "cv":
logger.info("cv 参数: %s", cv_kwargs)
if args.method == "shap":
logger.warning(
"shap 单张约 0.55 秒,%d 张预计 %.1f 分钟;只想试参数可用 --per-class / --limit",
@@ -284,6 +331,31 @@ def main() -> int:
processed += 1
if step % 20 == 0:
logger.info("处理 [%d/%d]", processed, len(dataset))
elif args.method == "cv":
from engine.classical import defect_saliency
for index, (path, label) in enumerate(dataset.samples):
target = label
if needs_model: # --target predicted:还是得过一遍模型
with torch.no_grad():
predicted = int(model(dataset[index]["image"][None].to(device)).argmax(1))
agree += int(predicted == label)
target = predicted
with Image.open(path) as raw:
image = raw.convert("L")
if args.cv_max_side:
image.thumbnail((args.cv_max_side, args.cv_max_side), Image.BILINEAR)
heat = defect_saliency(np.asarray(image), **cv_kwargs)
heat_shape = heat.shape
try:
_emit(out_dir, Path(path), classes, target, heat, box_kwargs,
args, box_counts, link_modes, seen)
except NameCollision as exc:
logger.error("%s", exc)
return 1
if index % 500 == 0:
logger.info("处理 [%d/%d]", index, len(dataset))
else:
from engine.shap_explain import shap_attribution, smooth
@@ -328,7 +400,9 @@ def main() -> int:
"box_params": box_kwargs,
"stage": stage if args.method == "gradcam" else None,
"max_evals": args.max_evals if args.method == "shap" else None,
"prediction_matches_folder_label": agree / total if total else 0.0,
"cv_params": cv_kwargs if args.method == "cv" else None,
# cv + --target label 时根本没加载模型,没有预测可比
"prediction_matches_folder_label": (agree / total if total else 0.0) if needs_model else None,
"boxes_per_image": {str(k): v for k, v in sorted(box_counts.items())},
"link_modes": dict(link_modes),
}
@@ -339,7 +413,8 @@ def main() -> int:
logger.info(
"完成 %d 张,热力图 %s,平均 %.3fs/张", total, heat_shape, summary["seconds_per_image"]
)
logger.info("模型预测与目录标注一致率: %.4f", summary["prediction_matches_folder_label"])
if summary["prediction_matches_folder_label"] is not None:
logger.info("模型预测与目录标注一致率: %.4f", summary["prediction_matches_folder_label"])
for count, number in sorted(box_counts.items()):
logger.info(" 生成 %d 个框: %d 张 (%.1f%%)", count, number, 100 * number / total)
if link_modes: