
7.4 多模态接触感知模型
在论文《AnyTouch: Learning Unified Static‑Dynamic Representation across Multiple Visuo‑tactile Sensors》中提出了一种统一的多传感器触觉表征学习框架,通过构建一个对齐的多模态触觉数据集TacQuad(包含来自GelSight Mini、DIGIT、DuraGel、Tac3D等多种视觉‑触觉传感器的静态图像与动态触觉视频),AnyTouch设计了多层次结构来同时学习 静态细节与动态触觉信息 的统一表征,从而增强模型对触觉信息的综合感知能力并实现跨传感器知识迁移。实验表明,该方法在离线数据集和真实动态任务(如倾倒任务)中表现优于现有方法,具备良好的跨传感器泛化能力与静/动态感知能力。
在人形机器人(humanoid robots)应用场景中,这类统一的触觉表征技术非常关键:人形机器人执行抓取、操控、装配等精细操作时,需要像人类一样融合视觉与触觉信息来判断物体属性(如材质、硬度、纹理)并实时调整动作;AnyTouch 可以提供一种基础感知层,使机器人能够跨不同触觉硬件标准统一理解触觉反馈,从而提升在复杂互动中的稳定性、可靠性与灵活性,例如在未知环境中稳定抓握、多手指协同操控、以及需要连续触觉反馈的人机接触任务等方面显著增强机器人性能。(触觉表征是人形机器人具身智能的核心模块,有助于实现更接近人类水平的触觉感知和操作控制。)
7.4.1 AnyTouch模型介绍
AnyTouch是一个专为解决 多种视觉‑触觉传感器异构数据融合与统一表征学习而设计的深度学习框架,它通过一套多层次结构同时处理 静态触觉图像与动态触觉视频,实现不同传感器之间的统一感知空间,从而增强跨传感器的知识迁移与综合感知能力。
- 核心设计理念
(1)静态‑动态统一输入:模型把传统触觉图像(静态)和触觉视频(动态)视为同一输入范式的一部分,通过将静态图像扩展成多帧形式,使网络能同时捕捉静态形状/纹理与动态摩擦/变形信息。
(2)多层次学习结构:AnyTouch的架构分为几个关键模块,每个模块负责不同层面的表征学习,从低层像素细节到高层语义一致性逐步抽象:
- Masked Modeling(掩码建模):对静态图像和动态视频均应用掩码自编码策略(类似 MAE),强制模型从部分可见像素中重建被掩盖区域,并附加动态的下一帧预测任务。这提升了对微观触觉细节(如纹理、接触变形)的学习能力。
- Multi‑Modal Aligning(多模态对齐):通过引入文本或其他辅助模态(如视觉或语言描述)作为"锚",AnyTouch 用对比学习等对齐方法让来自不同传感器的数据在高层语义空间中互相靠近,从而减少传感器间固有差异。
- Cross‑Sensor Matching(跨传感器匹配):设计了一种跨传感器匹配任务,使模型判断两段触觉输入是否来自同一物体的同一位置。这一任务促使模型学习对传感器无关、与物体触觉属性相关的共同特征,使不同触觉传感器的表征能够在统一空间聚类。
(3)多任务协同训练:AnyTouch 并不是单一损失优化,而是通过 掩码重建、下一帧预测、多模态对齐和跨传感器匹配 等任务的联合训练,使各层表征既互补又互相促进,从而获得更稳定、泛化更好的触觉表征能力。
-
模型能力特点
-
像素级细节捕捉:掩码建模使模型能关注触觉输入的微观结构,在细粒度识别(如材料纹理、微小压痕)上表现优异。
-
语义级特征对齐:多模态对齐和跨传感器匹配让不同传感器对同一触觉信息的表征在语义层面靠近,这对于跨传感器任务(如从一个传感器的数据迁移到另一个传感器)至关重要。
-
静态与动态兼顾:将静态图像作为动态序列的一部分处理,使模型在同一网络中同时理解静态属性与动态变化,提高了对动态操作(如滑动、倾倒等触觉变化场景)的感知能力。
-
训练与迁移机制
-
初始化与数据规模:AnyTouch 通常采用预训练视觉‑语言模型(如 CLIP)作为基础编码器,在大规模触觉数据集上进一步训练,以确保模型具备足够感知和迁移能力。
-
跨传感器迁移能力:模型训练时引入多种传感器的数据,使其学习到的表示空间能跨不同设备共享,这意味着即便在某些传感器上没有标签数据,模型仍能基于其他传感器的学习经验进行推断。
总而言之,AnyTouch模型通过统一处理静态与动态触觉输入、引入多模态对齐与跨传感器匹配任务,实现了对不同视觉‑触觉传感器数据的统一表征学习。其多层次结构不仅能够捕捉微观触觉细节,也能抽取高层语义特征,从而支持跨传感器迁移和复杂操作场景中的触觉感知。这使得AnyTouch成为人形机器人在抓取、操控和触觉交互任务中,实现精细、可靠且泛化能力强的触觉智能的重要基础模块。
7.4.2 实战演练:人形机器人触觉感知融合系统
本实例是专门为机器人抓取设计的多模态接触感知项目,整合了触觉阵列、六轴力传感器、视觉相机的多源数据,实现了接触力估计、滑移预警、抓取裕度分析等功能,包含了完整的数据集与模型训练代码,工程实用性强。
实例7-1 :人形机器人触觉感知融合系统(源码路径:codes\7\AnyTouch )
- 超参数配置
文件config.py定义了一个命令行参数解析器,用于配置AnyTouch模型训练和测试过程的各种超参数和设置,包括输出路径、设备选择、批量大小、训练轮数、学习率策略、数据增强参数、视频和传感器选项、MAE 及 LoRA 相关参数,以及分布式训练参数等。通过 argparse.ArgumentParser(),用户可以在命令行灵活地传入不同参数,以便控制训练行为和模型结构。
python
import argparse
def parse_args():
parser = argparse.ArgumentParser()
# ---------------------------
# 基本路径与设备
# ---------------------------
parser.add_argument('--output_dir', default='output_dir',
help='保存输出结果的路径,留空表示不保存')
parser.add_argument('--log_dir', default='output_dir',
help='TensorBoard 日志保存路径')
parser.add_argument('--device', default='cuda',
help='训练/测试使用的设备')
parser.add_argument('--accum_iter', default=1, type=int,
help='梯度累积次数(在显存受限时可增加有效批量大小)')
# ---------------------------
# 训练参数
# ---------------------------
parser.add_argument('--batch_size', default=64, type=int,
help='每个 GPU 的批量大小(有效批量大小 = batch_size * accum_iter * GPU 数量)')
parser.add_argument('--epochs', default=400, type=int)
# ---------------------------
# Masked/视频/传感器参数
# ---------------------------
parser.add_argument('--mask_ratio', type=float, default=0.75, help='掩码比例')
parser.add_argument("--norm_pix_loss", action='store_true', help="是否对像素损失进行归一化")
parser.set_defaults(norm_pix_loss=False)
parser.add_argument("--use_video", action='store_true', help="是否使用视频帧输入")
parser.add_argument("--use_sensor_token", action='store_true', help="是否使用传感器 token")
parser.add_argument("--use_same_patchemb", action='store_true', help="是否使用相同的 patch embedding")
parser.add_argument("--sensor_token_for_all", action='store_true', help="是否为所有输入使用传感器 token")
parser.add_argument("--beta_start", type=float, default=0.0, help="beta 初始值")
parser.add_argument("--beta_end", type=float, default=0.75, help="beta 结束值")
parser.add_argument("--new_decoder_sensor_token", action='store_true', help="是否在解码器使用新的传感器 token")
parser.add_argument("--alpha_vl", type=float, default=0.2, help="视觉-语言对齐权重")
parser.add_argument("--alpha_vt", type=float, default=0.2, help="视觉-触觉对齐权重")
parser.add_argument("--alpha_lt", type=float, default=1.0, help="语言-触觉对齐权重")
parser.add_argument("--TAG_times", type=int, default=1, help="TAG 数据集重复次数")
parser.add_argument("--cross_iter", type=int, default=6, help="跨模态迭代次数")
parser.add_argument("--cross_alpha", type=float, default=1.0, help="跨模态损失权重")
parser.add_argument("--no_mae", action='store_true', help="是否禁用 MAE 模块")
# ---------------------------
# 优化器参数
# ---------------------------
parser.add_argument('--weight_decay', type=float, default=0.05,
help='权重衰减系数 (默认 0.05)')
parser.add_argument('--lr', type=float, default=None, metavar='LR',
help='绝对学习率')
parser.add_argument('--blr', type=float, default=1e-3, metavar='LR',
help='基础学习率,绝对 lr = base_lr * total_batch_size / 256')
parser.add_argument('--min_lr', type=float, default=0., metavar='LR',
help='循环调度器的最小学习率下界')
parser.add_argument('--warmup_epochs', type=int, default=40, metavar='N',
help='学习率预热轮数')
parser.add_argument('--num_workers', type=int, default=32, metavar='N',
help='数据加载线程数')
parser.add_argument('--resume', default='',
help='从指定 checkpoint 恢复训练')
parser.add_argument('--mae_dir', default=None,
help='MAE checkpoint 路径')
parser.add_argument('--start_epoch', default=0, type=int, metavar='N',
help='开始训练的轮数')
parser.add_argument('--init_temp', type=float, default=0.07,
help='初始温度参数')
# ---------------------------
# LoRA 参数
# ---------------------------
parser.add_argument("--convert_to_lora", action='store_true', help="是否使用 LoRA")
parser.add_argument('--lora_r', type=int, default=16, help='LoRA r 参数')
parser.add_argument('--lora_alpha', type=int, default=16, help='LoRA alpha 参数')
parser.add_argument('--lora_dropout', type=float, default=0.0, help='LoRA dropout 概率')
parser.add_argument("--seed", type=int, default=0, help="随机种子")
# ---------------------------
# 分布式训练参数
# ---------------------------
parser.add_argument("--distributed", action='store_true', help="是否使用分布式训练")
parser.add_argument('--world_size', default=4, type=int,
help='分布式训练进程数量')
parser.add_argument('--local-rank', default=-1, type=int)
parser.add_argument('--dist_on_itp', action='store_true', help="是否在 ITP 环境下使用分布式")
parser.add_argument('--dist_url', default='env://',
help='分布式训练 URL 配置')
return parser
- 触觉图像数据集类
文件dataloader/downstream_dataset.py定义了多个触觉图像数据集类,用于深度学习训练和评估。这些类继承自torch.utils.data.Dataset,包括TAGDataset、OBJ2Dataset、OBJ1Dataset和FeelDataset,它们的功能是根据不同数据集格式加载触觉图像或触觉视频、读取标签、应用图像增强与标准化变换,然后返回可供模型训练使用的(图像,传感器类型,标签)三元组。其中还对训练集和测试集进行了不同的数据增强处理,如随机翻转、颜色扰动等,以提高模型的泛化能力。
python
# -------------------------------
# TAG 数据集类
# -------------------------------
class TAGDataset(Dataset):
def __init__(self, args, mode='train'):
TAG_dir = 'tactile_datasets/TAG/dataset/'
self.datalist = []
self.labels = []
self.sensor_type = []
# 根据训练或测试模式选择对应的 txt 文件
if mode == 'train':
if args.dataset == 'rough':
self.txt = 'tactile_datasets/TAG/train_rough.txt'
elif args.dataset == 'material' or args.dataset == 'hard':
self.txt = 'tactile_datasets/TAG/train.txt'
else:
if args.dataset == 'rough':
self.txt = 'tactile_datasets/TAG/test_rough.txt'
elif args.dataset == 'material' or args.dataset == 'hard':
self.txt = 'tactile_datasets/TAG/test.txt'
# 读取数据和标签
for line in open(self.txt):
item = line.split(',')[0]
label = int(line.split(',')[1])
if label == -1:
continue
# 对硬度任务做二分类处理
if args.dataset == 'hard':
if label in [7, 8, 9, 11, 13]:
label = 1
else:
label = 0
folder = item.split('/')[0]
image = item.split('/')[1]
self.datalist.append(TAG_dir + folder +'/gelsight_frame/'+ image)
self.labels.append(label)
self.sensor_type.append(0)
# 数据增强
if mode == 'train':
self.transform = transforms.Compose([
transforms.Resize(size=(224, 224)), # 调整图像尺寸
transforms.RandomHorizontalFlip(p=0.5), # 随机水平翻转
transforms.RandomVerticalFlip(p=0.5), # 随机垂直翻转
transforms.ColorJitter(brightness=0.4, contrast=0.4, saturation=0.5, hue=0.3), # 颜色扰动
transforms.ToTensor(), # 转为 Tensor
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) # 标准化
])
else:
self.transform = transforms.Compose([
transforms.Resize(size=(224, 224)),
transforms.ToTensor(),
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
])
def __len__(self):
return len(self.datalist)
def __getitem__(self, index):
img = Image.open(self.datalist[index]).convert('RGB') # 读取 RGB 图像
img = self.transform(img)
return img, self.sensor_type[index], self.labels[index]
# -------------------------------
# OBJ2.0 数据集类
# -------------------------------
class OBJ2Dataset(Dataset):
def __init__(self, args, mode='train'):
OBJ2_dir = 'tactile_datasets/obj2.0/touch/'
self.datalist = []
self.labels = []
self.sensor_type = []
self.mode = mode
self.label_json_dir = 'tactile_datasets/obj2.0/label.json'
self.split_json_dir = 'tactile_datasets/obj2.0/split.json'
self.label_dict = {}
# 读取标签文件
with open(self.label_json_dir, 'r') as file:
self.label_dict = json.load(file)
# 读取训练/测试划分
with open(self.split_json_dir, 'r') as file:
split_dict = json.load(file)
samples_list = split_dict[mode]
for item in samples_list:
item_id = item[0]
if int(item_id) <= 100: # OBJ2 数据集只使用 id > 100 的样本
continue
png_id = item[1]
self.datalist.append(OBJ2_dir + item_id +'/' + str(png_id) +'.png')
self.labels.append(int(self.label_dict[item_id]))
self.sensor_type.append(5)
print(len(self.datalist))
# 数据增强
if mode == 'train':
self.transform = transforms.Compose([
transforms.Resize(size=(224, 224)),
transforms.ToTensor(),
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
])
else:
self.transform = transforms.Compose([
transforms.Resize(size=(224, 224)),
transforms.ToTensor(),
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
])
def __len__(self):
return len(self.datalist)
def __getitem__(self, index):
img = Image.open(self.datalist[index]).convert('RGB')
img = self.transform(img)
return img, self.sensor_type[index], self.labels[index]
# -------------------------------
# OBJ1.0 数据集类
# -------------------------------
class OBJ1Dataset(Dataset):
def __init__(self, args, mode='train'):
OBJ1_dir = 'tactile_datasets/obj1.0/'
self.datalist = []
self.labels = []
self.sensor_type = []
self.mode = mode
# 与 OBJ2.0 共享标签文件
self.label_json_dir = 'tactile_datasets/obj2.0/label.json'
self.split_json_dir = 'tactile_datasets/obj2.0/split.json'
self.label_dict = {}
with open(self.label_json_dir, 'r') as file:
self.label_dict = json.load(file)
with open(self.split_json_dir, 'r') as file:
split_dict = json.load(file)
samples_list = split_dict[mode]
for item in samples_list:
item_id = item[0]
if int(item_id) > 100: # OBJ1 数据集只使用 id <= 100 的样本
continue
png_id = item[1]
self.datalist.append(OBJ1_dir + item_id +'/' + str(png_id) +'.png')
self.labels.append(int(self.label_dict[item_id]))
self.sensor_type.append(-1)
print(len(self.datalist))
# 数据增强
if mode == 'train':
self.transform = transforms.Compose([
transforms.Resize(size=(224, 224)),
transforms.RandomHorizontalFlip(p=0.5),
transforms.RandomVerticalFlip(p=0.5),
transforms.ColorJitter(brightness=0.1, contrast=0.1, saturation=0.1, hue=0.1),
transforms.ToTensor(),
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
])
else:
self.transform = transforms.Compose([
transforms.Resize(size=(224, 224)),
transforms.ToTensor(),
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
])
def __len__(self):
return len(self.datalist)
def __getitem__(self, index):
img = Image.open(self.datalist[index]).convert('RGB')
img = self.transform(img)
return img, self.sensor_type[index], self.labels[index]
# -------------------------------
# Feel 数据集类
# -------------------------------
class FeelDataset(Dataset):
def __init__(self, args, mode='train'):
TAG_dir = 'tactile_datasets/feel/'
self.datalist = []
self.labels = []
self.sensor_type = []
txt = open('tactile_datasets/feel/feel.csv', 'r')
split_dict = np.load('tactile_datasets/feel/split_'+str(args.split)+'.npy', allow_pickle=True).item()
name_list = split_dict[mode]
csv_reader = csv.reader(txt)
for row in csv_reader:
name = row[0]
png_id = row[1]
if name in name_list:
# 仅使用触摸过程中两帧图像
self.datalist.append([TAG_dir + name +'/touch_during/'+str(png_id)+'_A.png',
TAG_dir + name +'/touch_during/'+str(png_id)+'_B.png'])
self.labels.append(int(row[2]))
self.sensor_type.append(0)
print(len(self.datalist))
if mode == 'train':
self.transform = transforms.Compose([
transforms.Resize(size=(224, 224), antialias=True),
transforms.RandomHorizontalFlip(p=0.5),
transforms.RandomVerticalFlip(p=0.5),
transforms.ColorJitter(brightness=0.4, contrast=0.4, saturation=0.5, hue=0.3),
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
])
else:
self.transform = transforms.Compose([
transforms.Resize(size=(224, 224), antialias=True),
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
])
self.to_tensor = transforms.ToTensor()
def __len__(self):
return len(self.datalist)
def __getitem__(self, index):
# 读取触觉过程中两帧图像
img0 = Image.open(self.datalist[index][0]).convert('RGB')
img1 = Image.open(self.datalist[index][1]).convert('RGB')
img0 = self.to_tensor(img0).unsqueeze(0)
img1 = self.to_tensor(img1).unsqueeze(0)
img = torch.cat([img0, img1]) # 将两帧合并
touch = self.transform(img)
return touch, self.sensor_type[index], self.labels[index]
- CLIP增强
文件model/process_clip.py主要实现了CLIP模型视觉编码器的时间-空间增强模块,用于处理视频或多帧输入。核心功能如下所示:
- 定义全局配置(如帧数和patch dropout)并提供设置/获取接口;
- 扩展标准CLIP SpatialCLIPEncoderLayer,增加时间维度的embedding与注意力机制(CLIPEncoderLayer),实现对多帧序列的时序建模,同时保留原有空间注意力和MLP;
- 提供了多个工具函数,统计模型可训练参数、将模型转换为LoRA低秩适配版本、在编码器中添加时间注意力模块,以及调整位置嵌入以适应不同输入分辨。
总体来说,这个文件是CLIP 视频编码器处理与增强的核心实现,支持时间建模、LoRA微调、以及输入分辨率自适应。
文件model/process_clip.py的主要代码如下所示。
(1)下面代码的功能是 定义全局配置管理接口,用于设置和获取视频帧数量、patch dropout等全局参数。使用一个全局字典aaa来保存全局参数,例如帧数NUM_FRAMES和patch dropout。提供了set_global_value和get_global_value接口用于修改和读取全局参数,可在其他模块中动态调整视频帧数或dropout比例,方便统一管理。
python
# 全局参数字典,存储视频帧数和 patch dropout
aaa = {'NUM_FRAMES': 1, 'PATCH_DROPOUT': 0.0}
def set_global_value(k, v):
"""
设置全局参数
k: 参数名
v: 参数值
"""
global aaa
aaa[k] = v
def get_global_value():
"""
获取全局参数字典
"""
global aaa
return aaa
(2)下面代码的功能是扩展CLIP Encoder层,增加时间维度embedding和时间注意力模块,实现多帧视频的时序建模。类CLIPEncoderLayer继承自标准CLIP 的空间编码器层,增加temporal_embedding和temporal_attn。在forward中,先对输入hidden_states按时间维度重排并加上时间 embedding,然后通过时间注意力模块,再回到原来的形状。保留原空间注意力和MLP,结合残差连接,使模型能够同时处理时序信息和空间特征。
python
class CLIPEncoderLayer(SpatialCLIPEncoderLayer):
def __init__(self, config: CLIPConfig):
super().__init__(config)
self.T = config.num_frames // config.tube_size # 计算时间片段数
self.temporal_embedding = nn.Parameter(
torch.zeros(1, config.num_frames // config.tube_size, config.hidden_size)
)
nn.init.normal_(self.temporal_embedding, std=config.hidden_size ** -0.5) # 时间 embedding 初始化
self.embed_dim = config.hidden_size
self.temporal_attn = CLIPAttention(config) # 时间注意力模块
self.temporal_layer_norm1 = nn.LayerNorm(self.embed_dim, eps=config.layer_norm_eps)
self.gradient_checkpointing = False
def forward(
self,
hidden_states: torch.Tensor,
attention_mask: torch.Tensor,
causal_attention_mask: torch.Tensor,
output_attentions: Optional[bool] = False,
) -> Tuple[torch.FloatTensor]:
"""
hidden_states: 输入张量,形状 (batch, seq_len, embed_dim)
attention_mask: 注意力掩码
causal_attention_mask: 因果掩码
output_attentions: 是否输出注意力权重
"""
bt, n, d = hidden_states.shape
t = self.T
# 时间 embedding
if t != 1:
n = hidden_states.shape[1]
hidden_states = rearrange(hidden_states, '(b t) n d -> (b n) t d', t=t)
hidden_states = hidden_states + self.temporal_embedding[:, :t, :]
hidden_states = rearrange(hidden_states, '(b n) t d -> (b t) n d', n=n)
# 时间注意力
residual = hidden_states
hidden_states = rearrange(hidden_states, '(b t) n d -> (b n) t d', t=t)
hidden_states = self.temporal_layer_norm1(hidden_states)
hidden_states, attn_weights = self.temporal_attn(
hidden_states=hidden_states,
attention_mask=attention_mask,
causal_attention_mask=causal_attention_mask,
output_attentions=output_attentions,
)
hidden_states = residual + rearrange(hidden_states, '(b n) t d -> (b t) n d', n=n)
# 空间注意力
residual = hidden_states
hidden_states = self.layer_norm1(hidden_states)
hidden_states, attn_weights = self.self_attn(
hidden_states=hidden_states,
attention_mask=attention_mask,
causal_attention_mask=causal_attention_mask,
output_attentions=output_attentions,
)
hidden_states = residual + hidden_states
# MLP 层
residual = hidden_states
hidden_states = self.layer_norm2(hidden_states)
hidden_states = self.mlp(hidden_states)
hidden_states = residual + hidden_states
outputs = (hidden_states,)
if output_attentions:
outputs += (attn_weights,)
return outputs
(3)下面代码的功能是统计模型的可训练参数数量,辅助调试和模型分析。函数print_trainable_parameters用于遍历模型的所有参数,统计requires_grad=True的参数个数和总参数量,并打印可训练参数占比。用于检查模型微调、LoRA低秩适配后的训练参数分布。
python
def print_trainable_parameters(model, msg=''):
"""
打印模型可训练参数数量
model: torch.nn.Module 模型
msg: 打印信息前缀
"""
trainable_params = 0
all_param = 0
for _, param in model.named_parameters():
all_param += param.numel() # 累计总参数
if param.requires_grad:
trainable_params += param.numel() # 累计可训练参数
print(f"{msg} 可训练参数: {trainable_params} || 总参数: {all_param} || "
f"可训练占比: {100 * trainable_params / all_param:.2f}%")
(4)下面代码的功能是将视觉编码器转换为LoRA低秩适配版本,用于实现高效微调。函数convert_model_to_lora使用peft库将目标模块(如Q/K/V投影层)替换为低秩版本,减少微调参数量。可通过 args.lora_r、args.lora_alpha、args.lora_dropout等参数控制LoRA配置,最后打印可训练参数,确保转换生效。
python
def convert_model_to_lora(args, model):
"""
将视觉编码器模型转换为 LoRA 低秩适配版本
args: 命令行参数,包含 LoRA 配置
model: 包含 vision_model 的模型
"""
target_modules = ["k_proj", "v_proj", "q_proj", "out_proj"] # LoRA 应用目标模块
config = LoraConfig(
r=args.lora_r, # LoRA 低秩
lora_alpha=args.lora_alpha, # LoRA alpha
target_modules=target_modules,
lora_dropout=args.lora_dropout,
bias="none",
modules_to_save=[],
)
# 禁用梯度检查点
model.vision_model.encoder.is_gradient_checkpointing = False
# 使用 PEFT 获取 LoRA 模型
model.vision_model.encoder = get_peft_model(model.vision_model.encoder, config)
# 打印可训练参数信息
print_trainable_parameters(model.vision_model, msg='视觉编码器模型:')
(5)下面代码的功能是调整位置嵌入以适应不同图像分辨率或patch数量。resize_pos根据模型输入分辨率计算新的patch网格大小,将原始位置嵌入通过双三次插值调整为新的大小,保证模型在不同分辨率下仍能正常使用。还处理class token或其他额外token的拼接,完成位置嵌入更新。
python
def resize_pos(m: nn.Module, args):
"""
调整视觉编码器的位置嵌入以适应不同分辨率
m: 包含 position_embedding 的模型
args: 命令行参数,包含 device 信息
"""
# 设置图像大小
m.config.image_size = [m.image_size, m.image_size] if isinstance(m.image_size, int) else m.image_size
# 获取原始位置嵌入
old_pos_embed_state_dict = m.position_embedding.state_dict()
old_pos_embed = old_pos_embed_state_dict['weight']
dtype = old_pos_embed.dtype
# 计算 patch 网格大小
grid_size = [m.config.image_size[0] // m.patch_size, m.config.image_size[1] // m.patch_size]
extra_tokens = 1 # class token
new_seq_len = grid_size[0] * grid_size[1] + extra_tokens
if new_seq_len == old_pos_embed.shape[0]:
m.to(args.device)
return
# 更新模型属性
m.num_patches = grid_size[0] * grid_size[1]
m.num_positions = m.num_patches + 1
m.register_buffer("position_ids", torch.arange(m.num_positions).expand((1, -1)))
new_position_embedding = nn.Embedding(m.num_positions, m.embed_dim)
# 分离 class token 和图像 token
if extra_tokens:
pos_emb_tok, pos_emb_img = old_pos_embed[:extra_tokens], old_pos_embed[extra_tokens:]
else:
pos_emb_tok, pos_emb_img = None, old_pos_embed
old_grid_size = [int(math.sqrt(len(pos_emb_img)))]*2
# 调整图像 token 的位置嵌入
pos_emb_img = pos_emb_img.reshape(1, old_grid_size[0], old_grid_size[1], -1).permute(0, 3, 1, 2)
pos_emb_img = F.interpolate(
pos_emb_img,
size=grid_size,
mode='bicubic',
antialias=True,
align_corners=False,
)
pos_emb_img = pos_emb_img.permute(0, 2, 3, 1).reshape(1, grid_size[0] * grid_size[1], -1)[0]
# 合并 class token 和图像 token
if pos_emb_tok is not None:
new_pos_embed = torch.cat([pos_emb_tok, pos_emb_img], dim=0)
else:
new_pos_embed = pos_emb_img
old_pos_embed_state_dict['weight'] = new_pos_embed.to(dtype)
# 更新模型的 position_embedding
m.position_embedding = new_position_embedding
m.position_embedding.load_state_dict(old_pos_embed_state_dict)
m.to(args.device)
- 跨多视觉-触觉传感器的统一表征学习
文件model/multi_model.py围绕AnyTouch框架实现了跨多视觉-触觉传感器的统一静态-动态表征学习全流程,基于PyTorch并依托CLIP-ViT-L-14预训练模型构建核心网络(含MAE模型、多模态CLIP变体等),支持分阶段训练(MAE预训练、Align+Match对齐匹配),适配触觉图像/视频双模态输入与多传感器类型,提供分布式训练、梯度累积、LoRA轻量化微调等训练策略,配套线性探测模块针对材质识别、物体属性分类等触觉任务完成模型评估(涵盖准确率、损失等指标计算),同时封装了数据加载、配置解析、模型加载/保存、日志记录等通用功能,完整支撑触觉感知表征学习的训练与评估闭环。
文件model/multi_model.py的主要实现流程如下。
(1)下面代码实现了多模态CLIP模型的初始化逻辑,是AnyTouch框架的核心模型构建模块。原理上基于预训练CLIP的文本/视觉配置初始化对应分支,整合触觉MAE模块处理触觉数据,配置多模态对比损失的权重系数,初始化投影层将不同模态特征映射到统一表征空间,同时设置MAE预训练开关、传感器无关特征学习的核心参数。
python
class CLIPModel(CLIPPreTrainedModel):
config_class = CLIPConfig
_no_split_modules = ["CLIPTextEmbeddings", "CLIPEncoderLayer", "CLIPVisionEmbeddings"]
def __init__(self, args, config, decoder_config, num_frames, add_time_attn, tube_size):
super().__init__(config)
# 校验文本配置类型,确保为CLIPTextConfig
if not isinstance(config.text_config, CLIPTextConfig):
raise TypeError(
"config.text_config应是CLIPTextConfig类型,但当前是"
f" {type(config.text_config)}类型."
)
# 校验视觉配置类型,确保为CLIPVisionConfig
if not isinstance(config.vision_config, CLIPVisionConfig):
raise TypeError(
"config.vision_config应是CLIPVisionConfig类型,但当前是"
f" {type(config.vision_config)}类型."
)
# 初始化多模态对比损失的权重系数
self.alpha_vl = args.alpha_vl # 视觉-文本损失权重
self.alpha_vt = args.alpha_vt # 视觉-触觉损失权重
self.alpha_lt = args.alpha_lt # 文本-触觉损失权重
self.do_mae = True # 是否启用MAE预训练
self.no_text = args.no_text # 是否禁用文本分支
self.cross_alpha = args.cross_alpha # 跨传感器损失权重
if args.no_mae == True:
self.do_mae = False # 关闭MAE预训练
# 拆分文本和视觉配置
text_config = config.text_config
vision_config = config.vision_config
# 初始化投影维度、文本/视觉嵌入维度
self.projection_dim = config.projection_dim
self.text_embed_dim = text_config.hidden_size
self.vision_embed_dim = vision_config.hidden_size
# 初始化CLIP文本分支
text_model = CLIPTextModel._from_config(text_config, attn_implementation=config._attn_implementation)
self.text_model = text_model.text_model
# 初始化CLIP视觉分支
vision_model = CLIPVisionModel._from_config(vision_config, attn_implementation=config._attn_implementation)
self.vision_model = vision_model.vision_model
# 视觉特征投影层(映射到统一表征空间)
self.visual_projection = nn.Linear(self.vision_embed_dim, self.projection_dim, bias=False)
# 文本特征投影层(映射到统一表征空间)
self.text_projection = nn.Linear(self.text_embed_dim, self.projection_dim, bias=False)
# CLIP温度系数参数
self.logit_scale = nn.Parameter(torch.tensor(self.config.logit_scale_init_value))
# 跨传感器匹配头
self.cross_sensor_head = nn.Linear(self.projection_dim, 1)
# 二分类损失函数
self.bceloss = nn.BCEWithLogitsLoss()
# 权重初始化与后处理
self.post_init()
# 初始化触觉视频MAE模型(处理触觉动态序列)
self.touch_mae_model = TactileVideoMAE(args, config, decoder_config, num_frames, add_time_attn, tube_size)
(2)下面代码实现了MAE(掩码自编码器)的随机掩码核心逻辑,是AnyTouch第一阶段MAE预训练的关键。原理上通过生成随机噪声对输入序列按样本维度洗牌,选取指定比例的未掩码特征,生成二进制掩码矩阵标记掩码区域,同时返回恢复索引用于解码器重构,实现自监督学习中"掩码-重构"的核心前置步骤。
python
def random_masking(self, sequence, noise=None):
"""
按样本维度执行随机掩码(通过样本内洗牌实现)。
样本内洗牌通过对随机噪声排序实现,主要用于测试阶段控制随机性和保证可复现性。
参数:
sequence (`torch.LongTensor` 形状为 `(batch_size, sequence_length, dim)`): 输入序列特征
noise (`torch.FloatTensor` 形状为 `(batch_size, sequence_length)`, 可选): 控制随机性的噪声
"""
batch_size, seq_length, dim = sequence.shape
# 计算需要保留的序列长度(1 - 掩码比例)
len_keep = int(seq_length * (1 - self.mask_ratio))
# 生成随机噪声(0-1之间)
if noise is None:
noise = torch.rand(batch_size, seq_length, device=sequence.device)
# 对每个样本的噪声排序(升序:小值保留,大值掩码)
ids_shuffle = torch.argsort(noise, dim=1).to(sequence.device)
# 生成恢复索引(用于后续重构时还原序列顺序)
ids_restore = torch.argsort(ids_shuffle, dim=1).to(sequence.device)
# 选取前len_keep个保留的索引
ids_keep = ids_shuffle[:, :len_keep]
# 按保留索引提取未掩码的序列特征
sequence_unmasked = torch.gather(sequence, dim=1, index=ids_keep.unsqueeze(-1).repeat(1, 1, dim))
# 生成二进制掩码:0表示保留,1表示掩码
mask = torch.ones([batch_size, seq_length], device=sequence.device)
mask[:, :len_keep] = 0
# 洗牌还原,得到与原始序列对应的掩码
mask = torch.gather(mask, dim=1, index=ids_restore)
return sequence_unmasked, mask, ids_restore
(3)下面代码是线性探测模块的前向传播逻辑,用于AnyTouch模型的下游任务评估(如材质、粗糙度、物体分类)。原理上冻结预训练的触觉特征提取器(CLIPVisionTransformer),仅训练分类头;根据数据集类型处理输入维度,通过CLS/全局平均池化提取特征,最后通过分类头输出预测结果,实现对预训练表征的无偏评估。
python
def forward(self, x, sensor_type = None):
# 处理Feel数据集的输入(时序维度展平)
if self.dataset == 'feel':
B, T, C, H, W = x.shape
x = x.view(B*T, C, H, W) # 展平批次和时序维度
sensor_type = sensor_type.repeat(T) # 扩展传感器类型标签匹配展平后维度
# 若使用相同的patch嵌入,将输入扩展为3通道(匹配视觉分支输入)
if self.use_same_patchemb:
x = x.unsqueeze(1).repeat(1, 3, 1, 1, 1)
# 冻结特征提取器(不计算梯度)
with torch.no_grad():
# 提取触觉特征
x = self.touch_model(x, sensor_type = sensor_type)
# 按池化方式提取特征
if self.pooling == 'cls':
out = self.touch_projection(x.pooler_output) # CLS token特征
else:
out = self.touch_projection(x.last_hidden_state) # 所有token特征
# CLS池化:直接通过分类头预测
if self.pooling == 'cls':
if self.dataset == 'feel':
_, d = out.shape
out = out.view(B, -1, d) # 还原批次维度
out = out.flatten(1) # 展平时序和特征维度
out = self.head(out) # 分类头预测
# 全局平均池化:对非CLS/sensor token求平均后预测
elif self.pooling == 'global':
if self.dataset == 'feel':
_, N, d = out.shape
out = out.view(B, N, -1, d)
out = out.flatten(2)
if self.use_sensor_token:
# 跳过CLS和sensor token,对剩余token平均
out = self.head(out[:, 6:, :].mean(dim=1))
else:
# 跳过CLS token,对剩余token平均
out = self.head(out[:, 1:, :].mean(dim=1))
return out
(4)下面代码实现了多模态对比损失的计算逻辑,是AnyTouch第二阶段Align+Match训练的核心。原理上分别计算视觉-文本、视觉-触觉、触觉-文本三个模态对的双向对比损失(文本-图像/图像-文本、触觉-图像/图像-触觉、触觉-文本/文本-触觉),通过预设权重系数加权求和,实现多模态特征在统一空间的对齐。
python
def clip_loss(self, logits_per_touch_image, logits_per_text_image, logits_per_touch_text):
# 初始化各模态对的损失(初始化为0)
touch_image_loss = torch.zeros(1, device = self.device) # 触觉-图像损失
image_touch_loss = torch.zeros(1, device = self.device) # 图像-触觉损失
text_image_loss = torch.zeros(1, device = self.device) # 文本-图像损失
image_text_loss = torch.zeros(1, device = self.device) # 图像-文本损失
touch_text_loss = torch.zeros(1, device = self.device) # 触觉-文本损失
text_touch_loss = torch.zeros(1, device = self.device) # 文本-触觉损失
# 计算触觉-图像双向对比损失
if logits_per_touch_image is not None:
touch_image_loss = self.contrastive_loss(logits_per_touch_image)
image_touch_loss = self.contrastive_loss(logits_per_touch_image.t())
# 计算文本-图像双向对比损失
if logits_per_text_image is not None:
text_image_loss = self.contrastive_loss(logits_per_text_image)
image_text_loss = self.contrastive_loss(logits_per_text_image.t())
# 计算触觉-文本双向对比损失
if logits_per_touch_text is not None:
touch_text_loss = self.contrastive_loss(logits_per_touch_text)
text_touch_loss = self.contrastive_loss(logits_per_touch_text.t())
# 按权重加权求和所有损失
if (logits_per_touch_image is not None) or (logits_per_text_image is not None) or (logits_per_touch_text is not None):
return self.alpha_vl*(text_image_loss + image_text_loss) / 2.0 + self.alpha_vt*(touch_image_loss + image_touch_loss) / 2.0 + self.alpha_lt*(touch_text_loss + text_touch_loss)
else:
return None
(5)下面代码实现了MAE编码器的前向传播逻辑,是触觉数据自监督表征学习的核心。原理是调用自定义的 CLIPVisionTransformer 处理触觉像素输入,得到编码器的隐藏状态(含掩码后的特征),再通过投影层将特征映射到指定维度,同时返回掩码矩阵和恢复索引,为解码器重构掩码区域提供输入,实现触觉特征的自监督学习。
python
def forward_encoder(self, x, sensor_type=None):
"""
MAE编码器前向传播:处理触觉输入,提取掩码后的特征并投影
参数:
x: 触觉像素输入(图像/视频帧)
sensor_type: 传感器类型标签(区分不同触觉传感器)
返回:
out: 投影后的编码器特征
mask: 二进制掩码矩阵
ids_restore: 序列恢复索引
"""
# 调用自定义touch_model前向方法,得到掩码后的特征、掩码、恢复索引
x, mask, ids_restore = self.touch_model(x, sensor_type=sensor_type)
# 提取编码器最后一层隐藏状态并投影到指定维度
out = self.touch_projection(x.last_hidden_state)
return out, mask, ids_restore
@add_start_docstrings_to_model_forward(CLIP_VISION_INPUTS_DOCSTRING)
@replace_return_docstrings(output_type=BaseModelOutputWithPooling, config_class=CLIPVisionConfig)
def touch_forward(
self,
pixel_values: Optional[torch.FloatTensor] = None,
output_attentions: Optional[bool] = None,
output_hidden_states: Optional[bool] = None,
return_dict: Optional[bool] = None,
sensor_type = None
) -> Union[Tuple, BaseModelOutputWithPooling]:
"""返回编码器输出(含掩码特征、掩码矩阵、恢复索引)"""
# 校验输出注意力、隐藏状态、返回字典的配置(未指定则用模型默认配置)
output_attentions = output_attentions if output_attentions is not None else self.touch_model.config.output_attentions
output_hidden_states = (
output_hidden_states if output_hidden_states is not None else self.touch_model.config.output_hidden_states
)
return_dict = return_dict if return_dict is not None else self.touch_model.config.use_return_dict
# 校验像素输入是否为空
if pixel_values is None:
raise ValueError("必须指定pixel_values输入")
# 调用自定义嵌入层,得到掩码后的隐藏状态、掩码、恢复索引
hidden_states, mask, ids_restore = self.touch_model.embeddings(pixel_values, sensor_type = sensor_type)
# 预层归一化
hidden_states = self.touch_model.pre_layrnorm(hidden_states)
# 编码器前向传播
encoder_outputs = self.touch_model.encoder(
inputs_embeds=hidden_states,
output_attentions=output_attentions,
output_hidden_states=output_hidden_states,
return_dict=return_dict,
)
# 提取最后一层隐藏状态和池化输出
last_hidden_state = encoder_outputs[0]
pooled_output = last_hidden_state[:, 0, :]
pooled_output = self.touch_model.post_layernorm(pooled_output)
# 按返回格式返回结果
if not return_dict:
return (last_hidden_state, pooled_output) + encoder_outputs[1:]
return BaseModelOutputWithPooling(
last_hidden_state=last_hidden_state,
pooler_output=pooled_output,
hidden_states=encoder_outputs.hidden_states,
attentions=encoder_outputs.attentions,
), mask, ids_restore
到此为止,本实例的核心功能介绍完毕。总之,本实例展示了在人形机器人项目中,如何利用改进的CLIP视觉模型对多帧视频或传感器数据进行特征提取与时间建模,通过时间注意力模块、位置编码调整及可选LoRA微调,实现对机器人视觉感知的强化,为动作理解、环境交互和运动决