Files
Shaun ZhouandClaude Opus 5 65bc6b2045 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>
2026-09-17 11:03:13 +08:00

127 lines
5.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""传统 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