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:
co-authored by
Claude Opus 5
parent
32e56cb230
commit
65bc6b2045
@@ -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
|
||||
Reference in New Issue
Block a user