背景差分:大半径方框均值估背景 → 残差(可选极性)→ 平滑 → 有无判定 → 按峰值归一化, 再交给与 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>
127 lines
5.8 KiB
Python
127 lines
5.8 KiB
Python
"""传统 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
|