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
+42 -7
View File
@@ -531,7 +531,7 @@ model:
## 从分类到定位:生成框标签
分类模型只学过"这是什么缺陷",没学过"缺陷在哪"。归因方法能把分类分数反推回空间位置,
阈值化 + 取连通域外接矩形,就得到弱监督的框标签:
阈值化 + 取连通域外接矩形,就得到弱监督的框标签。另外还有一条不依赖模型的传统 CV 路线:
```bash
# Grad-CAM(默认,全量 11k 张约 2 分钟)
@@ -539,6 +539,9 @@ uv run python scripts/generate_boxes.py --out runs/boxes_gradcam --method gradca
# 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
```
**默认对全量数据生成**;`--limit` / `--per-class` 只用于快速试参数。
@@ -562,18 +565,50 @@ runs/<name>/
两者只在预测与目录不一致的图上有区别——`boxes_shap` 那次是 0.85%,约 95 张。
`summary.json` 里的 `prediction_matches_folder_label` 记录两者的一致率(实测 0.987)。
### 两种方法的取舍
### 三种方法的取舍
| | `gradcam` | `shap` |
| --- | --- | --- |
| 分辨率 | 受限于特征图(layer3 @128² 只有 8×8) | 与输入同分辨率(128×128) |
| 单张耗时 | ~0.01 秒 | ~0.55 秒 |
| 小目标 | 几像素的黑点在 8×8 上不可分辨 | 能贴合到几十像素的目标 |
| | `gradcam` | `shap` | `cv` |
| --- | --- | --- | --- |
| 依赖分类模型 | 是 | 是 | **否** |
| 分辨率 | 受限于特征图(layer3 @128² 只有 8×8) | 与输入同分辨率(128×128) | 原图分辨率 |
| 单张耗时 | ~0.01 秒 | ~0.55 秒 | ~0.011 秒 |
| 小目标 | 几像素的黑点在 8×8 上不可分辨 | 能贴合到几十像素的目标 | 贴合好 |
| 找的是什么 | 模型据以判断类别的区域 | 同左,更精细 | 偏离局部背景的区域 |
实测框面积占比(越小越贴合缺陷本体):`blackdot` 0.019(shap) vs 0.269(gradcam)、
`mosquito` 0.030 vs 0.173、`whitedot` 0.076 vs 0.253。小目标类别上 SHAP 明显更准,
代价是慢 50 倍。SHAP 需要 `uv sync --group explain`。
### 传统 CV:背景差分(`--method cv`)
实现在 [engine/classical.py](engine/classical.py):大半径方框均值估背景 → 残差(可选只看
比背景暗/亮的一侧)→ 小半径平滑 → 判定有无缺陷 → 按峰值归一化,最后交给和另外两种方法
**同一套** `cam_to_boxes` 出框。只依赖 numpy。
它与归因方法有三点本质差别:
- **不需要分类模型**,`--checkpoint` 都不用给,分类器还没训练出来就能先跑一版框;
也因此不受分类精度影响。
- **类别只能来自目录**(`--target label`),它自己不判类。
- **找的是"看起来和周围不一样"的区域**,不是"模型据以判断类别的区域"。对
`blackdot` / `whitedot` / `mosquito` 这类局部目标效果好;`ripple` / `wrinkle` 这种铺满整图的
纹理类本来就没有局部目标,它给不出有意义的框。
**有无缺陷是显式判定的**,这点和归因方法不同。残差峰值要同时高于绝对对比度下限
(`--cv-min-contrast`,默认 0.05≈13 个灰阶)和噪声水平的 15 倍,否则直接返回全零图、出 0 个框。
噪声水平用残差的 MAD 估计。这条判定是必需的:实测纯噪声图的"残差峰值 / MAD"稳定在 9 附近
(最大 10.9,几乎与噪声大小无关),而有一个对比度 0.25 的黑点时是 25~330,两者分得很开;
若只按峰值归一化,一张正常图的噪声会被拉到 1,满图假框。所以正常图天然得到空标签。
调参:`--cv-radius`(背景半径,0=按图取 `min(高,宽)//8`;太小会把缺陷自身算进背景)、
`--cv-polarity`(`dark` 黑点蚊虫 / `bright` 白点反光 / `both`)、`--cv-min-contrast`、
`--smooth-radius`(与 shap 共用)、`--cv-max-side`(分析前缩边长,默认 256——框是归一化的,
缩放不影响标签,但能挡住纯 Python 的连通域标记在大图上变慢)。
> **已知局限。** 背景估计在图像边界处会对强烈的明暗阶跃产生响应,所以贴着边框的
> 明暗交界可能被框出来。对 `edge_clean` / `edge_dirty` 这种本来就在拍边缘的类别,这是真实信号;
> 对其他类别则是假框,复核时留意。
### 调参
- `--max-boxes`(默认 1):一张裁片通常只对应一处缺陷,多出来的框多半是噪声
+126
View File
@@ -0,0 +1,126 @@
"""传统 CV 的缺陷显著图:不依赖任何模型,靠"偏离局部背景"定位。
薄膜缺陷(黑点、白点、蚊虫、油污、条纹……)的共同点是**局部灰度偏离周围背景**,
而背景自身还有大尺度的亮度不均(照明、卷材张力、镜头暗角)。所以:
1. 大半径方框均值估计背景;
2. 残差 = 图像 − 背景,按极性取暗 / 亮 / 双向;
3. 小半径平滑压掉像素噪声;
4. 归一化到 [0, 1],交给 `engine.gradcam.cam_to_boxes` 出框——与 gradcam / shap 共用同一套
阈值、连通域、面积下限逻辑。
与两种归因方法的关键差别:
- **不需要分类模型**,也就不受分类精度影响,训练分类器之前就能跑;
- 它找的是"看起来和周围不一样"的区域,而不是"模型据以判断类别"的区域。纹理类缺陷
(ripple、wrinkle 这种铺满整图的)本来就没有局部目标,它给不出有意义的框;
- 类别只能来自图片所在目录的人工标注(`--target label`),它自己不判类。
归一化那一步有个坑:直接除以最大值的话,一张完全正常、只有噪声的图,噪声会被拉到 1,
于是满图假框;把除数换成某个固定下限也不行——噪声除以下限往往还是压不到阈值以下。
所以这里先做一次**显式的有无判定**:残差峰值达不到"噪声水平"与"绝对对比度下限"两条线,
就返回全零图,出 0 个框,这正是 normal 应有的结果;过了线才按峰值归一化。
噪声水平用 MAD(中位绝对偏差)估计,对少量缺陷像素不敏感——缺陷只占图的百分之几时,
中位数仍然落在背景上。实测(64~512 尺寸、σ 从 0.003 到 0.05):
- 纯噪声图的"残差峰值 / MAD"稳定在 9 附近,最大约 10.9,且几乎与噪声大小无关
(这是极值统计的性质,不是巧合);
- 有一个对比度 0.25 的黑点时,这个比值是 25~330。
两者分得很开,所以 `noise_sigmas` 取 15:高于噪声的 10.9,又远低于真实缺陷的 25。
"""
from __future__ import annotations
import numpy as np
POLARITIES = ("dark", "bright", "both")
#: 亮度按 ITU-R BT.601 加权;本项目的图基本是灰度,彩色输入时这样更接近人眼观感
LUMA = np.array([0.299, 0.587, 0.114], dtype=np.float32)
def box_mean(array: np.ndarray, radius: int) -> np.ndarray:
"""方框均值滤波,边缘用 edge 填充。用积分图做到与半径无关的 O(1) 每像素。"""
if radius <= 0:
return array
size = 2 * radius + 1
padded = np.pad(array, radius, mode="edge")
integral = padded.cumsum(axis=0).cumsum(axis=1)
integral = np.pad(integral, ((1, 0), (1, 0)), mode="constant")
height, width = array.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]
)
return total / (size * size)
def to_gray(image: np.ndarray) -> np.ndarray:
"""转成 [0, 1] 的单通道浮点图。接受 HxW 或 HxWxC;uint8 按 255 归一。"""
array = np.asarray(image)
if array.ndim == 3:
if array.shape[2] >= 3:
array = array[..., :3].astype(np.float32) @ LUMA
else:
array = array[..., 0]
elif array.ndim != 2:
raise ValueError(f"期望 HxW 或 HxWxC 的图像,得到 shape={array.shape}")
array = array.astype(np.float32)
return array / 255.0 if array.max() > 1.0 else array
def defect_saliency(
image: np.ndarray,
background_radius: int = 0,
polarity: str = "both",
smooth_radius: int = 1,
min_contrast: float = 0.05,
noise_sigmas: float = 15.0,
) -> np.ndarray:
"""背景差分显著图,归一化到 [0, 1],可直接喂给 `cam_to_boxes`。
Args:
image: HxW 或 HxWxC,uint8 或浮点。
background_radius: 背景估计半径,0 = 按图取 min(高, 宽) // 8。
太小会把缺陷自身算进背景(残差被自己抵消),太大则跟不上照明变化。
polarity: dark=只找比背景暗的(黑点、蚊虫),bright=只找更亮的(白点、反光),
both=取绝对值。默认 both。
smooth_radius: 残差平滑半径,压掉孤立噪点,0 = 不平滑。
min_contrast: 绝对对比度下限(0~1 灰度单位)。残差峰值低于它就认为整图没有缺陷。
0.05 约合 13 个灰阶。
noise_sigmas: 峰值还要高于噪声水平的这个倍数。噪声水平由残差的 MAD 估计,
所以噪点多的图自动要求更高的对比度。默认 15,见模块说明里的实测数据。
Returns:
与输入同分辨率的 [0, 1] 显著图;判定为无缺陷时是全零图。
"""
if polarity not in POLARITIES:
raise ValueError(f"未知 polarity: {polarity},可选 {POLARITIES}")
gray = to_gray(image)
height, width = gray.shape
radius = background_radius or max(4, min(height, width) // 8)
background = box_mean(gray, radius)
if polarity == "dark":
residual = background - gray
elif polarity == "bright":
residual = gray - background
else:
residual = np.abs(gray - background)
residual = np.maximum(residual, 0.0)
if smooth_radius > 0:
residual = box_mean(residual, smooth_radius)
# 有无判定。两条线都要过:绝对对比度(缺陷得肉眼可见)与噪声倍数(噪点多的图要求更严)。
# 只按峰值归一化的话,平坦图的噪声峰值会被拉到 1,满图假框。
noise = 1.4826 * float(np.median(np.abs(residual - np.median(residual))))
peak = float(residual.max())
if peak < max(float(min_contrast), noise_sigmas * noise):
return np.zeros_like(residual)
return residual / peak
+4 -13
View File
@@ -65,19 +65,10 @@ 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)
# 与 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
+89 -14
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,6 +238,11 @@ def main() -> int:
if args.limit:
dataset.samples = dataset.samples[: args.limit]
# 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
@@ -215,7 +253,7 @@ def main() -> int:
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,6 +413,7 @@ def main() -> int:
logger.info(
"完成 %d 张,热力图 %s,平均 %.3fs/张", total, heat_shape, summary["seconds_per_image"]
)
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)
+137
View File
@@ -0,0 +1,137 @@
"""传统 CV 的缺陷显著图(背景差分)。"""
import pytest
np = pytest.importorskip("numpy")
from engine.classical import box_mean, defect_saliency, to_gray # noqa: E402
from engine.gradcam import cam_to_boxes # noqa: E402
BOX_KWARGS = {"threshold": 0.4, "min_area_ratio": 0.002, "max_boxes": 1, "peak_ratio": 0.75}
def _image(size=96, level=0.5, seed=0, noise=0.01):
rng = np.random.default_rng(seed)
return np.clip(level + rng.normal(0, noise, (size, size)), 0, 1).astype(np.float32)
def _with_blob(image, center=(30, 70), radius=4, delta=-0.25):
ys, xs = np.ogrid[: image.shape[0], : image.shape[1]]
blob = (ys - center[0]) ** 2 + (xs - center[1]) ** 2 <= radius**2
out = image.copy()
out[blob] = np.clip(out[blob] + delta, 0, 1)
return out
def test_locates_a_dark_spot():
"""黑点应该落在框里,而且框不能大到没有意义。"""
saliency = defect_saliency(_with_blob(_image()))
boxes = cam_to_boxes(saliency, **BOX_KWARGS)
assert len(boxes) == 1
x1, y1, x2, y2 = boxes[0]
assert x1 <= 70 / 96 <= x2 and y1 <= 30 / 96 <= y2 # 覆盖缺陷中心
assert (x2 - x1) * (y2 - y1) < 0.1 # 不到整图的 10%
@pytest.mark.parametrize("noise", [0.005, 0.01, 0.03])
def test_flat_image_gets_no_box(noise):
"""只有噪声的正常图必须出 0 个框,噪声大小不同都要成立。
回归:若只按峰值归一化,噪声峰值会被拉到 1,满图假框;改成先判定有无缺陷。
"""
saliency = defect_saliency(_image(noise=noise, seed=1))
assert not saliency.any()
assert cam_to_boxes(saliency, **BOX_KWARGS) == []
def test_illumination_gradient_is_not_a_defect():
"""大尺度亮度不均(照明、暗角)属于背景,不该被当成缺陷。"""
ramp = np.linspace(0.3, 0.7, 96, dtype=np.float32)
gradient = np.repeat(ramp[None, :], 96, axis=0)
assert cam_to_boxes(defect_saliency(gradient), **BOX_KWARGS) == []
# 同一张图上的黑点仍要找得到
boxes = cam_to_boxes(defect_saliency(_with_blob(gradient)), **BOX_KWARGS)
assert len(boxes) == 1
@pytest.mark.parametrize(
"polarity,delta,expected",
[("dark", -0.25, 1), ("dark", 0.25, 0), ("bright", 0.25, 1), ("bright", -0.25, 0),
("both", -0.25, 1), ("both", 0.25, 1)],
)
def test_polarity_selects_dark_or_bright(polarity, delta, expected):
image = _with_blob(_image(), delta=delta)
saliency = defect_saliency(image, polarity=polarity)
assert len(cam_to_boxes(saliency, **BOX_KWARGS)) == expected
def test_rejects_unknown_polarity():
with pytest.raises(ValueError, match="polarity"):
defect_saliency(_image(), polarity="darker")
def test_output_range_and_shape():
saliency = defect_saliency(_with_blob(_image(size=64)))
assert saliency.shape == (64, 64)
assert saliency.min() >= 0.0 and saliency.max() <= 1.0
def test_accepts_uint8_rgb_and_grayscale():
"""uint8 / 浮点、单通道 / 三通道都要能进来,结果一致。"""
gray = _with_blob(_image())
as_uint8 = (gray * 255).astype(np.uint8)
rgb = np.repeat(as_uint8[..., None], 3, axis=2)
from_float = cam_to_boxes(defect_saliency(gray), **BOX_KWARGS) # noqa: F841
from_uint8 = cam_to_boxes(defect_saliency(as_uint8), **BOX_KWARGS)
from_rgb = cam_to_boxes(defect_saliency(rgb), **BOX_KWARGS)
assert from_uint8 == from_rgb
assert np.allclose(from_float, from_uint8, atol=0.05)
def test_to_gray_rejects_odd_shapes():
with pytest.raises(ValueError, match="HxW"):
to_gray(np.zeros((5,), dtype=np.float32))
def test_default_radius_scales_with_the_image():
"""background_radius=0 时按 min(高,宽)//8 取值。"""
image = _with_blob(_image(size=96), radius=3)
assert np.array_equal(
defect_saliency(image), defect_saliency(image, background_radius=12)
)
def test_faint_defect_is_rejected_by_min_contrast():
"""对比度低于下限的起伏不算缺陷;调低下限就能找出来。"""
faint = _with_blob(_image(noise=0.005), delta=-0.04)
assert cam_to_boxes(defect_saliency(faint), **BOX_KWARGS) == []
assert len(cam_to_boxes(defect_saliency(faint, min_contrast=0.01), **BOX_KWARGS)) == 1
def test_noisy_image_needs_more_contrast():
"""噪声大的图自动要求更高对比度:同一个弱缺陷在干净图上能找到,在噪声图上不算数。"""
weak = -0.05 # 实测比值:σ=0.002 时 109,σ=0.04 时 9.8
assert len(cam_to_boxes(
defect_saliency(_with_blob(_image(noise=0.002), delta=weak), min_contrast=0.01),
**BOX_KWARGS,
)) == 1
assert cam_to_boxes(
defect_saliency(_with_blob(_image(noise=0.04), delta=weak), min_contrast=0.01),
**BOX_KWARGS,
) == []
def test_box_mean_matches_a_naive_average():
rng = np.random.default_rng(3)
array = rng.random((12, 15))
radius = 2
padded = np.pad(array, radius, mode="edge")
expected = np.empty_like(array)
for y in range(array.shape[0]):
for x in range(array.shape[1]):
expected[y, x] = padded[y:y + 2 * radius + 1, x:x + 2 * radius + 1].mean()
assert np.allclose(box_mean(array, radius), expected)
assert np.array_equal(box_mean(array, 0), array) # radius=0 原样返回
+87 -3
View File
@@ -1,4 +1,4 @@
"""生成框标签的统一入口(gradcam / shap 两种方法)。"""
"""生成框标签的统一入口(gradcam / shap / cv 三种方法)。"""
import json
import sys
@@ -70,8 +70,8 @@ def base_argv(project, out, method):
"--checkpoint", str(checkpoint), "--config", str(config)]
@pytest.mark.parametrize("method", ["gradcam", "shap"])
def test_both_methods_produce_a_yolo_dataset(project, tmp_path, monkeypatch, method):
@pytest.mark.parametrize("method", ["gradcam", "shap", "cv"])
def test_every_method_produces_a_yolo_dataset(project, tmp_path, monkeypatch, method):
if method == "shap":
pytest.importorskip("shap")
root, _, _ = project
@@ -292,3 +292,87 @@ def test_stage_minus_one_selects_the_final_output(project, tmp_path, monkeypatch
out = tmp_path / "out"
assert run(base_argv(project, out, "gradcam") + ["--stage", "-1"], monkeypatch) == 0
assert json.loads((out / "summary.json").read_text("utf-8"))["stage"] is None
# ---------------- 传统 CV 方法 ----------------
def test_cv_runs_without_a_classifier(project, tmp_path, monkeypatch):
"""cv 不判类别,也就不需要权重——分类器还没训练出来时就能先跑一版框。"""
root, _, config = project
out = tmp_path / "out_cv"
argv = ["--out", str(out), "--method", "cv", "--config", str(config),
"--checkpoint", str(tmp_path / "does_not_exist.pth")]
assert run(argv, monkeypatch) == 0
assert len(list((out / "labels").rglob("*.txt"))) == 5
summary = json.loads((out / "summary.json").read_text(encoding="utf-8"))
assert summary["method"] == "cv"
# 没加载模型,就没有预测可比
assert summary["prediction_matches_folder_label"] is None
assert summary["cv_params"]["polarity"] == "both"
def test_cv_labels_use_the_folder_class(project, tmp_path, monkeypatch):
"""类别只能来自目录:cv 自己不判类。"""
root, _, config = project
out = tmp_path / "out_cv"
assert run(["--out", str(out), "--method", "cv", "--config", str(config)], monkeypatch) == 0
classes = sorted(p.name for p in root.iterdir() if p.is_dir())
for label in (out / "labels").rglob("*.txt"):
folder = label.parent.name
for line in label.read_text(encoding="utf-8").strip().splitlines():
assert classes[int(line.split()[0])] == folder
def test_cv_finds_a_planted_defect(tmp_path, monkeypatch):
"""端到端:图上放一个黑点,框要套住它;平坦的图不出框。"""
root = tmp_path / "dataset"
(root / "blackdot").mkdir(parents=True)
(root / "normal").mkdir(parents=True)
rng = np.random.default_rng(0)
flat = np.clip(rng.normal(140, 2, (96, 96)), 0, 255).astype(np.uint8)
spotted = flat.copy()
spotted[28:36, 60:68] = 40 # 一个明显的黑点
Image.fromarray(spotted).save(root / "blackdot" / "spot.png")
Image.fromarray(flat).save(root / "normal" / "flat.png")
config = tmp_path / "cfg.yaml"
config.write_text(
f"""
device: cpu
data:
root: {root}
classes: null
exclude: []
val_transforms: [{{name: Resize, size: [64, 64]}}, {{name: ToTensor}}]
loader: {{batch_size: 2, num_workers: 0}}
model: {{name: classifier, backbone: {{name: torchvision, arch: resnet18, pretrained: false}}}}
""",
encoding="utf-8",
)
out = tmp_path / "out"
assert run(["--out", str(out), "--method", "cv", "--config", str(config)], monkeypatch) == 0
spot = (out / "labels" / "blackdot" / "spot.txt").read_text(encoding="utf-8").split()
assert len(spot) == 5
_, cx, cy, width, height = (float(v) for v in spot)
assert abs(cx - 64 / 96) < 0.1 and abs(cy - 32 / 96) < 0.1 # 命中黑点
assert width * height < 0.15
# 平坦的图没有缺陷,应当是空标签
assert (out / "labels" / "normal" / "flat.txt").read_text(encoding="utf-8") == ""
def test_cv_polarity_is_passed_through(project, tmp_path, monkeypatch):
root, _, config = project
out = tmp_path / "out_cv"
argv = ["--out", str(out), "--method", "cv", "--config", str(config),
"--cv-polarity", "dark", "--cv-radius", "5", "--cv-min-contrast", "0.02"]
assert run(argv, monkeypatch) == 0
params = json.loads((out / "summary.json").read_text(encoding="utf-8"))["cv_params"]
assert params == {
"background_radius": 5, "polarity": "dark", "smooth_radius": 2, "min_contrast": 0.02,
}