From 65bc6b20456b265bf907b6cc6f8cfb53f35b8533 Mon Sep 17 00:00:00 2001 From: Shaun Zhou Date: Thu, 17 Sep 2026 11:03:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(boxes):=20=E6=96=B0=E5=A2=9E=E4=BC=A0?= =?UTF-8?q?=E7=BB=9F=20CV=20=E5=AE=9A=E4=BD=8D=E6=96=B9=E6=B3=95=EF=BC=88-?= =?UTF-8?q?-method=20cv=EF=BC=89=EF=BC=8C=E4=B8=8D=E4=BE=9D=E8=B5=96?= =?UTF-8?q?=E5=88=86=E7=B1=BB=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景差分:大半径方框均值估背景 → 残差(可选极性)→ 平滑 → 有无判定 → 按峰值归一化, 再交给与 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 --- README.md | 49 +++++++++++-- engine/classical.py | 126 ++++++++++++++++++++++++++++++++ engine/shap_explain.py | 17 +---- scripts/generate_boxes.py | 121 +++++++++++++++++++++++++------ tests/test_classical.py | 137 +++++++++++++++++++++++++++++++++++ tests/test_generate_boxes.py | 90 ++++++++++++++++++++++- 6 files changed, 494 insertions(+), 46 deletions(-) create mode 100644 engine/classical.py create mode 100644 tests/test_classical.py diff --git a/README.md b/README.md index f362344..6b68385 100644 --- a/README.md +++ b/README.md @@ -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// 两者只在预测与目录不一致的图上有区别——`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):一张裁片通常只对应一处缺陷,多出来的框多半是噪声 diff --git a/engine/classical.py b/engine/classical.py new file mode 100644 index 0000000..ac457f6 --- /dev/null +++ b/engine/classical.py @@ -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 diff --git a/engine/shap_explain.py b/engine/shap_explain.py index f11b9e0..eeaa623 100644 --- a/engine/shap_explain.py +++ b/engine/shap_explain.py @@ -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 diff --git a/scripts/generate_boxes.py b/scripts/generate_boxes.py index 6b4070e..6224243 100644 --- a/scripts/generate_boxes.py +++ b/scripts/generate_boxes.py @@ -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: diff --git a/tests/test_classical.py b/tests/test_classical.py new file mode 100644 index 0000000..d6b0323 --- /dev/null +++ b/tests/test_classical.py @@ -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 原样返回 diff --git a/tests/test_generate_boxes.py b/tests/test_generate_boxes.py index e5db281..505c3f9 100644 --- a/tests/test_generate_boxes.py +++ b/tests/test_generate_boxes.py @@ -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, + }