Vision Transformer(VIT)是CLIP架构的视觉塔部分,这部分实现了将图片转化为1*D的特征向量,并使用了分块处理的思想。

Patch Embedding

切片->序列化:

VIT首先用了切片序列操作,将一张二维图像切片分成很多个小patch,定义如下:

  • 将输入的二维图像(2D Image)转换为一维的序列化嵌入(1D Sequence of Embeddings),使其能够像文本 Token 一样被 Transformer 编码器处理。
  • 核心动机: Transformer 最初是为 NLP 设计的,它处理的是序列数据。Patch Embedding 的作用就是将 “像素的排列组合” 转化为 “视觉单词的序列”

在代码落地时,ViT 并没有真的用 for 循环去裁剪图片(那样太慢了)。它用了一个非常优雅的技巧:使用一个步长(Stride)等于卷积核大小(Kernel Size)的二维卷积。

代码逻辑演示如下:

import torch
import torch.nn as nn

# 假设输入是 [Batch, Channel, H, W]
x = torch.randn(1, 3, 224, 224)

# Patch Embedding 映射层
patch_embed = nn.Conv2d(in_channels=3, out_channels=768, kernel_size=16, stride=16)

# 执行卷积
# 输出维度: [1, 768, 14, 14]
x = patch_embed(x)

# 展平 (Flatten) 变成序列
# 维度变为: [1, 768, 196] -> 再转置 [1, 196, 768]
x = x.flatten(2).transpose(1, 2) 

print(x.shape) # 输出 torch.Size([1, 196, 768])

这里的尺寸是224*224 通过这个二维卷积可以分别从宽高进行切片 切片为224/16 * 224/16 = 196个patch

输出通道数channel=768 因为要保证数据总和不变

然后对输出数据进行展平(对尺寸维度),变成序列,在进行维度转置,符合B, N, C的形式 最后输出

** 为什么卷积可以等同于“切片+投影”?**

这是一个非常妙的数学转换:

切片(Patching): 卷积核的大小(16 * 16)正好覆盖一个 Patch。投影(Projection): 卷积核内的参数就是权重。当卷积核在一个位置做点积运算时,它实际上是在把这 16 *16 * 3个像素值线性映射成一个数值。

步长(Stride): 步长设为16,意味着卷积核跳着走,不会重复采样像素,完美模拟了“不重叠切片”。

并行化: 卷积层可以利用 GPU 的张量加速引擎(Tensor Cores)同时计算所有 Patch,这比手动 view 和 permute 要快得多。

  • 卷积运算的本质是:卷积核内的参数与图像像素进行点积(Dot Product)
  • 在一个 16×16×316 \times 16 \times 316×16×3 的区域内做卷积,等价于把这 768 个像素拉平,然后输入到一个神经元数量为 embed_dim (768) 的全连接层。
  • 结论:Conv2d(kernel=16, stride=16) 实际上就是在图像的每一个 Patch 位置,同步执行了一次局部全连接映射

**插入 全局变量CLS Token **

在所有的视觉Patch向量之前,会手动插入一个随机初始化的、可学习的向量,增加一个“全局总结位”。

插入后,向量的shape从[1, 196, 768]变成了[1, 197, 768], 即一个CLS+196个patch的视觉单词

# 1. 初始化一个可学习的 CLS Token (所有 Batch 共享)
# 维度是 [1, 1, 768]
cls_token = nn.Parameter(torch.zeros(1, 1, 768))

# 2. 将其扩展到与当前 Batch Size 一致
# 假设 Batch Size 是 B
#在 expand 函数中,参数 -1 的含义是:“保持该维度的大小不变”。
cls_tokens = cls_token.expand(B, -1, -1) 

# 3. 使用 torch.cat 拼接到序列最前面
# x 的维度从 [B, 196, 768] 变为 [B, 197, 768]
x = torch.cat((cls_tokens, x), dim=1)

位置向量编码:

Transformer 内部的 Self-Attention 机制是“无序”的。如果不加处理,模型分不清 Patch 1(左上角)和 Patch 196(右下角)的区别 。而在实际领域应用中,位置信息极其重要

  • 原理: 准备一个与 x 形状完全一样的矩阵 pos_embed(维度 [1, 197, 768])。
  • 操作: 并不是拼接,而是直接相加(Element-wise Add)。
  • **公式: **

代码实现:

# 1. 初始化位置编码矩阵 (197 代表 1个CLS + 196个Patch)
pos_embed = nn.Parameter(torch.zeros(1, 197, 768))

# 2. 直接与特征向量相加
# 广播机制会自动处理 Batch 维度
x = x + pos_embed

完整流程:

步骤 操作 维度变化 (Base 模型) 目的
0 输入图像 <font style="color:rgb(68, 71, 70);">[B, 3, 224, 224]</font> 原始数据
1 Conv2d (Patching) <font style="color:rgb(68, 71, 70);">[B, 768, 14, 14]</font> 像素映射到高维特征
2 Flatten & Transpose <font style="color:rgb(68, 71, 70);">[B, 196, 768]</font> 转化为 Transformer 序列格式
3 Concat CLS Token <font style="color:rgb(68, 71, 70);">[B, 197, 768]</font> 增加一个专门负责全局分类的位
4 Add Pos Embed <font style="color:rgb(68, 71, 70);">[B, 197, 768]</font> 注入空间位置信息
5 输入 Transformer 层 <font style="color:rgb(68, 71, 70);">[B, 197, 768]</font> 开始进行 Self-Attention 交互
Transformer Encoder

这是VIT最重要的部分之一,即著名的Transformer架构–Self Attention(自注意力机制)。这个机制的本质就是让每个单独的patch向量,两两进行交互,计算彼此相关性。

核心向量: Q K V

为了实现这种相关性交互,每个单独向量都会通过三个不同的全连接层,创建出三个向量:

  • Query (Q - 查询):像是一个搜索框。“我想看看有没有和我类似的边缘特征?”
  • Key (K - 键):像是一个标签。“我这里有一段 45 度的边缘特征。”
  • Value (V - 值):像是在搜索结果中提取的实际内容。“我是具体的像素特征信息。

数学公式(一定要记得):

Attention(Q, K, V) = Softmax((Q*K^T / dk^1/2)*V)

即:

步骤拆解:

第一步:计算相似度(Q 乘 K 的转置)

我们将 Q 矩阵和 K 矩阵做点积。如果 Q 和 K 的方向很接近(相似度高),点积值就大。结果是一个 197 *197的矩阵。每一行代表:当前的这个 Patch 对所有 Patch 的关注程度。

第二步:缩放(Scaling)

除以
,dk是向量维度。

目的是防止点积的结果过大,导致后续Softmax梯度消失。这一步为了训练的数值稳定性。

第三步: 归一化(Softmax)

这一步是对每一行做Softmax,让权重之和等于1。

这一步就可以产生所谓AttentionMap 注意力图。

第四步:加权求和

用算出来的权重乘以V。

这样会让每个Patch结果不是孤立的,而是融合了全局信息的特征向量。

为什么用Multi-Self-Attention,即多头注意力机制:

如果用单个attention,模型就像是只有一个摄像头一样,关注的特征比较单一;

在工业级半导体缺陷检测中,一个 Patch 可能包含多重信息。假设晶圆上有一个带颜色变化的划痕

  • 头 A (空间位置):它发现这个 Patch 附近有一系列连续的像素突变,判定为“线状物体”。
  • 头 B (颜色/对比度):它发现这个 Patch 的亮度比周围高出 30%,判定为“金属感”。
  • 头 C (纹理):它发现这个 Patch 破坏了原本均匀的电路格栅,判定为“结构破损”。

如果是单个头的话,模型只能在这些特征中做取舍,最终只能获得一个较为模糊的平均特征。多头注意力机制可以并行工作,最后把不同信息拼接起来,得到一个较为具体的特征描述。

底层逻辑: 多头注意力允许模型同时建模图像中不同性质的关联(比如长距离的形状关联和短距离的纹理关联)。

代码实现如下:

import torch.nn as nn
import torch

class VITPatchEmbedding(nn.Module):
    def __init__(self, img_size = 224, patch_size = 16, in_channel = 3, embed_dim = 768):
        super().__init__()
        self.patch_size = patch_size
        #使用Conv2d卷积实现patch
        self.proj = nn.Conv2d(in_channel, embed_dim, kernel_size=patch_size, stride=patch_size)
    def forward(self, x):
        # x: [Batch, 3, 224, 224]
        x = self.proj(x) # [B, 768, 224/16, 224/16]
        x = x.flatten(2).transpose(1, 2) #[B, 196, 768]
        return x
class VITInput(nn.Module):
    def __init__(self, img_size=224, patch_size=16, embed_dim=768):
        super().__init__()
        self.patch_embed = nn.Conv2d(3, embed_dim, kernel_size=patch_size, stride=patch_size)
        num_patches = (img_size//patch_size)**2
        self.cls_token = nn.Parameter(torch.zeros(1, 1, embed_dim))
        self.pos_embed = nn.Parameter(torch.zeros(1, num_patches+1, embed_dim))
    def forward(self, x):
        B = x.shape[0]
        x = self.patch_embed(x)
        x = x.flatten(2).transpose(1,2)
        # add cls token
        cls_token = self.cls_token.expand(B, -1, -1)
        x = torch.cat((cls_token, x), dim=1)    # 按channel维度拼接
        #add pos token
        pos_token = self.pos_embed
        x = x + pos_token
        return x
class MultiSelfAttention(nn.Module):
    def __init__(self, embed_dim=768, num_heads=12):
        super().__init__()
        self.num_heads = num_heads
        self.head_dim = embed_dim//num_heads
        self.scale = self.head_dim**-0.5 # 缩放系数 根号下dk分之一
        #Q, K, V的线性投影矩阵
        self.qkv = nn.Linear(embed_dim, embed_dim*3)
        self.proj = nn.Linear(embed_dim, embed_dim)

    def forward(self, x):
        B, N, C = x.shape #[Batch, 197, 768]
        #1. 算出Q, K, V并切成多头
        #[B, N, 3*C] -> [B, N, 3, num_heads, head_dim] -> [3, B, num_heads, N, head_dim]
        qkv = self.qkv(x)
        qkv = qkv.reshape(B, N, 3, self.num_heads, self.head_dim)
        qkv = qkv.permute(2, 0, 3, 1, 4) # [3, B, num_heads, N, head_dim]
        Q, K, V =qkv[0], qkv[1], qkv[2] #[ B, num_heads, N, head_dim ]
        #2. 计算相似度矩阵Q*K^T, 乘以缩放系数
        attention = Q @ K.transpose(-2, -1) * self.scale # 这里将K矩阵的最后两个维度转置,做乘法的时候就会只计算最后两个维度的
        attention = attention.softmax(dim=-1) # [B, num_heads, N, N] 这就是我们要提取的 Attention Map (12个头)
        #3. 加权求和, 拼接多头
        x = attention @ V # [B, num_heads, N, N]@[B, num_heads, N, head_dim] -> [B, num_heads, N, head_dim]
        x = x.transpose(1,2).reshape(B, N, C)
        #4. 线性映射 (融合各头信息)
        x = self.proj(x)
        return x, attention
class TransformerBlock(nn.Module):
    def __init__(self, embed_dim = 768, num_heads=12, mlp_ratio=4.0):
        super().__init__()
        self.norm1 = nn.LayerNorm(embed_dim)
        self.attn = MultiSelfAttention(embed_dim, num_heads)
        self.norm2 = nn.LayerNorm(embed_dim)
        # mlp层:增加非线性表达能力
        self.mlp = nn.Sequential(nn.Linear(embed_dim, int(embed_dim*mlp_ratio)),
                                 nn.GELU(),
                                 nn.Linear(int(embed_dim*mlp_ratio), embed_dim)
                                 )
    def forward(self, x):
        residual = x
        x, attn_map = self.attn(self.norm1(x))
        x = x + residual
        x = x + self.mlp(self.norm2(x))
        return x, attn_map





if __name__  == "__main__":
    model = VITInput()
    img = torch.randn(1, 3, 224, 224)
    output1 = model(img)
    print(f"VIT Input输出形状: {output1.shape}") # 预期: torch.Size([1, 197, 768])
    attn = TransformerBlock()
    output2, attn_map = attn(output1)
    print(f"VIT Transformer输出形状:{output2.shape}")

为什么在Block中要加入MLP架构:

  1. 首先是Attn的局限性。 自注意力机制本质上是做加权求和。无论权重的 Softmax 怎么算,它依然是输入向量的线性组合。如果只堆叠 Attention,模型就只是在不断地把像素“搬来搬去”。

    这里MLP会关注非线性特征, 在注意力机制帮每个 Patch 收集到了周围的信息后,MLP 通过 `Linear -> GELU -> Linear` 这一套组合拳,对每个 Patch 内部的 768 维特征进行**非线性映射和变换**。它负责把收集到的“原材料”加工成更高阶的“语义特征”。  
    
  2. MLP也起到了一个空间升维的作用,通过ratio升维参数。因为在高维空间中,特征更容易被线性分离。这就像是在半导体检测中,把微小的信号放大,在更高的维度去观察缺陷的特征,然后再压缩回原来的维度。

  3. MLP 对每个 Patch 是独立运行的。这意味着它在强化每个位置自身的表达,确保模型不仅知道“哪里的 Patch 相关”,还知道“这个 Patch 本身到底是什么”。

VIT在CLIP中的具体应用与特征可视化分析

下面的程序实现了从CLIP中提取VIT视觉特征,并将每个Patch与目标提示词做特征计算,将特征热力图可视化,最后实现输出热力图和模型预测类型。这里提取的就是最后一层的AttnMap进行可视化,效果确实一般。

import torch
import clip
import numpy as np
import cv2
import matplotlib.pyplot as plt
from PIL import Image

def run_clip_spatial_analysis(image_path, class_names, target_class="a wafer with scratch"):
    device = "cuda" if torch.cuda.is_available() else "cpu"
    model, preprocess = clip.load("ViT-B/32", device=device)

    # 1. 加载并预处理图像
    orig_image = Image.open(image_path).convert("RGB")
    image = preprocess(orig_image).unsqueeze(0).to(device)

    # 2. 提取文本特征(作为“搜索关键词”)
    text_tokens = clip.tokenize(class_names).to(device)
    with torch.no_grad():
        text_features = model.encode_text(text_tokens)
        text_features /= text_features.norm(dim=-1, keepdim=True)
        # 3. 提取图像的 Patch 特征
        # 注意:model.encode_image 通常返回全局 CLS 特征。
        # 为了拿 Patch 特征,需要 hook 最后一层或者使用内部方法
        # 对于 ViT-B/32,最后一层输出形状通常是 [50, B, 512] (1个CLS + 49个Patch)
        # 1. 进入视觉编码器的初始嵌入
        # 使用image.half()将图片的像素数据从 32位浮点数 (Float32) 转换为 16位半精度浮点数 (Float16),因为CLIP不支持float32
        x = model.visual.conv1(image.half())  # [B, width, 7, 7]
        x = x.reshape(x.shape[0], x.shape[1], -1).permute(0, 2, 1)  # [B, 49, width]

        # 2. 添加 [CLS] token 并叠加位置编码
        cls_token = model.visual.class_embedding.to(x.dtype)
        x = torch.cat([cls_token + torch.zeros(x.shape[0], 1, x.shape[-1], dtype=x.dtype, device=x.device), x], dim=1)
        x = x + model.visual.positional_embedding.to(x.dtype)
        x = model.visual.ln_pre(x)
        # 3. 通过 Transformer (注意:timm/openai 的顺序是 [N, B, C])
        x = x.permute(1, 0, 2)  # [50, B, width]
        x = model.visual.transformer(x)
        x = x.permute(1, 0, 2)  # [B, 50, width]
        # 4. 最后层归一化
        x = model.visual.ln_post(x)  # [B, 50, width]
        # 5. 【核心修复】使用矩阵乘法进行投影,而不是调用
        # x @ model.visual.proj -> 映射到 CLIP 的共同嵌入空间 (512维)
        if model.visual.proj is not None:
            x = x @ model.visual.proj  # 这里解决了 Parameter not callable 的报错

        # 6. 分离全局特征与 Patch 特征
        # 第 0 位是经过投影的全局特征 (原本用于分类)
        # 1~49 是各个空间位置的特征
        patch_features = x[0, 1:, :]  # [49, 512]
        patch_features /= patch_features.norm(dim=-1, keepdim=True)

        global_features = x[0, 0, :].unsqueeze(0)  # [1, 512]
        global_features /= global_features.norm(dim=-1, keepdim=True)

        # --- 5. 计算空间热力图 (确保在 with torch.no_grad 内部或特征已提取) ---
        # 假设我们要搜寻的目标在 class_names 中的索引
        target_idx = class_names.index(target_class)
        target_text_vec = text_features[target_idx]  # [512]

        # 计算 49 个 Patch 与该目标的余弦相似度
        # patch_features: [49, 512], target_text_vec: [512]
        # 执行点积得到 [49] 个相似度数值
        spatial_sim = (patch_features @ target_text_vec.unsqueeze(1)).squeeze()

        # 将 [49] 维向量重新排列为 [7, 7] 的空间网格
        # 注意:ViT-B/32 是 7x7,如果是 ViT-B/16 请改为 14, 14
        grid_size = int(np.sqrt(patch_features.shape[0]))
        spatial_sim = spatial_sim.reshape(grid_size, grid_size).cpu().float().numpy()

        # --- 6. 增强可视化效果 ---
        # 指数增强:拉开背景与缺陷的差距
        spatial_sim = np.exp(spatial_sim * 2.0)

        # 归一化到 0-1
        spatial_sim = (spatial_sim - spatial_sim.min()) / (spatial_sim.max() - spatial_sim.min() + 1e-8)

        # 使用双三次插值上采样到 224x224,消除马赛克感
        heatmap = cv2.resize(spatial_sim, (224, 224), interpolation=cv2.INTER_CUBIC)

        # 转换为彩色热力图
        heatmap_color = cv2.applyColorMap(np.uint8(255 * heatmap), cv2.COLORMAP_JET)

        # 图像融合
        img_display = np.array(orig_image.resize((224, 224)))
        if img_display.ndim == 2: img_display = cv2.cvtColor(img_display, cv2.COLOR_GRAY2RGB)
        overlay = cv2.addWeighted(img_display, 0.5, heatmap_color, 0.5, 0)

        # --- 8. 计算并打印所有类别的得分 ---
        # 计算全局图像特征与所有文本特征的相似度
        # global_features: [1, 512], text_features: [N, 512]
        logits_per_image = 100.0 * global_features @ text_features.T
        probs = logits_per_image.softmax(dim=-1).cpu().numpy()[0]

        # 排序并打印结果
        print(f"\n{'-' * 10} 预测得分 {'-' * 10}")
        # zip 将类别名和概率捆绑,sorted 进行降序排列
        top_results = sorted(zip(my_classes, probs), key=lambda x: x[1], reverse=True)

        for name, score in top_results:
            print(f"{name:>25}: {score * 100:>6.2f}%")

        # 获取最终预测结果
        top_name, top_score = top_results[0]
        print(f"\n最终预测结果: 这张图最可能是 [{top_name}],置信度: {top_score * 100:.2f}%")

        # --- 9. 展示结果 ---
        plt.figure(figsize=(10, 5))
        plt.imshow(cv2.cvtColor(overlay, cv2.COLOR_BGR2RGB))
        plt.title(f"CLIP Semantic Search: {target_class}")
        plt.axis('off')
        plt.show()


if __name__ == "__main__":
    my_classes = ["a perfect wafer", "some dirty particles on a shiny silicon wafer surface", "a dog", "a cat", "a scratch on a shiny silicon wafer surface"]
    img_path = r"E:\Datasets\VLM_Test\CLIP_Test\61.tiff"
    run_clip_spatial_analysis(img_path, my_classes, target_class="some dirty particles on a shiny silicon wafer surface")

首先我们可以输入一张狗狗的图片做测试:

模型得到结果如下:

模型输出的Patch-Prompt特征热力图如下:

再做一个测试,我们输入一张带有脏污颗粒的晶圆图像如下:

此时模型输出结果如下:

然后特征热力图如下:

可以发现,尽管模型预测的很准确,但是具体到每个Patch的特征比较模糊,并不是我们预想的那样将和目标prompt对应的视觉特征标红,即不能对应上,所以CLIP模型是一个全局的整体预测,并不能细致分割目标。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐