"""传统 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