在自定义数据集上实现OpenAI CLIP

news2025/1/6 9:17:08

在2021年1月,OpenAI宣布了两个新模型:DALL-E和CLIP,它们都是以某种方式连接文本和图像的多模态模型。CLIP全称是Contrastive Language–Image Pre-training,一种基于对比文本-图像对的预训练方法。为什么要介绍CLIP呢?因为现在大火得Stable Diffusion 并不是单一模型,而是多个模型组成。其中会用到一个 Text encoder 将用户的文本输入进行编码,这个 text encoder 就是 CLIP 模型中 text encoder

CLIP模型在训练时,可以给它一个输入句子,并提取最相关的图像来配合它。CLIP学习了一个完整的句子和它所描述的图像之间的关系。也就是说它是在完整的句子上训练的,而不是像“汽车”、“狗”等离散的分类,这一点对于应用至关重要。当训练完整的短语时,模型可以学习更多的东西,并识别照片和文本之间的模式。他们还证明,当在相当大的照片和与之相对应的句子数据集上进行训练时,该模型是可以作为分类器的。CLIP在发布的时候能在无任何微调的情况下(zero-shot ),在 ImageNet 数据集上的分类表现超 ResNets-50 微调后的效果,也就是说他是非常有用的。

所以在本文中,我们将使用PyTorch中从头开始实现CLIP模型,以便我们对CLIP有一个更好的理解

这里就需要用到2个库:timm和transformers,我们先导入代码

 import os
 import cv2
 import gc
 import numpy as np
 import pandas as pd
 import itertools
 from tqdm.autonotebook import tqdm
 import albumentations as A
 import matplotlib.pyplot as plt
 
 import torch
 from torch import nn
 import torch.nn.functional as F
 import timm
 from transformers import DistilBertModel, DistilBertConfig, DistilBertTokenizer

下一步就是预处理数据和通用配置config。config是一个普通的python文件,我们将所有的超参数放在里面,如果使用Jupyter Notebook的情况下,它是一个在Notebook开头定义的类。

 class CFG:
     debug = False
     image_path = "../input/flickr-image-dataset/flickr30k_images/flickr30k_images"
     captions_path = "."
     batch_size = 32
     num_workers = 4
     head_lr = 1e-3
     image_encoder_lr = 1e-4
     text_encoder_lr = 1e-5
     weight_decay = 1e-3
     patience = 1
     factor = 0.8
     epochs = 2
     device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
 
     model_name = 'resnet50'
     image_embedding = 2048
     text_encoder_model = "distilbert-base-uncased"
     text_embedding = 768
     text_tokenizer = "distilbert-base-uncased"
     max_length = 200
 
     pretrained = True # for both image encoder and text encoder
     trainable = True # for both image encoder and text encoder
     temperature = 1.0
 
     # image size
     size = 224
 
     # for projection head; used for both image and text encoders
     num_projection_layers = 1
     projection_dim = 256 
     dropout = 0.1

还有一些我们自定义指标的辅助类

 class AvgMeter:
     def __init__(self, name="Metric"):
         self.name = name
         self.reset()
 
     def reset(self):
         self.avg, self.sum, self.count = [0] * 3
 
     def update(self, val, count=1):
         self.count += count
         self.sum += val * count
         self.avg = self.sum / self.count
 
     def __repr__(self):
         text = f"{self.name}: {self.avg:.4f}"
         return text
 
 def get_lr(optimizer):
     for param_group in optimizer.param_groups:
         return param_group["lr"]

我们的目标是描述图像和句子。所以数据集必须同时返回句子和图像。所以需要使用DistilBERT标记器对句子(标题)进行标记,然后将标记id (input_ids)和注意掩码提供给DistilBERT。DistilBERT比BERT 模型要小,但是模型的结果都差不多,所以我们选择使用它。

下一步就是使用HuggingFace tokenizer进行标记化。在__init__中获得的tokenizer对象,将在模型运行时加载。标题被填充并截断到预定的最大长度。在加载相关图像之前,我们将在**getitem**中加载一个编码的标题,这是一个带有键input_ids和attention_mask的字典,并对其进行转换和扩充(如果有的话)。然后把它变成一个张量,并以“image”作为键存储在字典中。最后我们将标题的原始文本与关键字“标题”一起输入字典。

 class CLIPDataset(torch.utils.data.Dataset):
     def __init__(self, image_filenames, captions, tokenizer, transforms):
         """
         image_filenames and cpations must have the same length; so, if there are
         multiple captions for each image, the image_filenames must have repetitive
         file names 
         """
 
         self.image_filenames = image_filenames
         self.captions = list(captions)
         self.encoded_captions = tokenizer(
             list(captions), padding=True, truncation=True, max_length=CFG.max_length
         )
         self.transforms = transforms
 
     def __getitem__(self, idx):
         item = {
             key: torch.tensor(values[idx])
             for key, values in self.encoded_captions.items()
         }
 
         image = cv2.imread(f"{CFG.image_path}/{self.image_filenames[idx]}")
         image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
         image = self.transforms(image=image)['image']
         item['image'] = torch.tensor(image).permute(2, 0, 1).float()
         item['caption'] = self.captions[idx]
 
         return item
 
 
     def __len__(self):
         return len(self.captions)
 
 
 
 def get_transforms(mode="train"):
     if mode == "train":
         return A.Compose(
             [
                 A.Resize(CFG.size, CFG.size, always_apply=True),
                 A.Normalize(max_pixel_value=255.0, always_apply=True),
             ]
         )
     else:
         return A.Compose(
             [
                 A.Resize(CFG.size, CFG.size, always_apply=True),
                 A.Normalize(max_pixel_value=255.0, always_apply=True),
             ]
         )

图像和文本编码器:我们将使用ResNet50作为图像编码器。

 class ImageEncoder(nn.Module):
     """
     Encode images to a fixed size vector
     """
 
     def __init__(
         self, model_name=CFG.model_name, pretrained=CFG.pretrained, trainable=CFG.trainable
     ):
         super().__init__()
         self.model = timm.create_model(
             model_name, pretrained, num_classes=0, global_pool="avg"
         )
         for p in self.model.parameters():
             p.requires_grad = trainable
 
     def forward(self, x):
         return self.model(x)

使用DistilBERT作为文本编码器。使用CLS令牌的最终表示来获得句子的整个表示。

 class TextEncoder(nn.Module):
     def __init__(self, model_name=CFG.text_encoder_model, pretrained=CFG.pretrained, trainable=CFG.trainable):
         super().__init__()
         if pretrained:
             self.model = DistilBertModel.from_pretrained(model_name)
         else:
             self.model = DistilBertModel(config=DistilBertConfig())
             
         for p in self.model.parameters():
             p.requires_grad = trainable
 
         # we are using the CLS token hidden representation as the sentence's embedding
         self.target_token_idx = 0
 
     def forward(self, input_ids, attention_mask):
         output = self.model(input_ids=input_ids, attention_mask=attention_mask)
         last_hidden_state = output.last_hidden_state
         return last_hidden_state[:, self.target_token_idx, :]

上面的代码已经将图像和文本编码为固定大小的向量(图像2048,文本768),我们需要图像和文本具有相似的尺寸,以便能够比较它们,所以我们把2048维和768维向量投影到256维(projection_dim),只有维度相同我们才能比较它们。

 class ProjectionHead(nn.Module):
     def __init__(
         self,
         embedding_dim,
         projection_dim=CFG.projection_dim,
         dropout=CFG.dropout
     ):
         super().__init__()
         self.projection = nn.Linear(embedding_dim, projection_dim)
         self.gelu = nn.GELU()
         self.fc = nn.Linear(projection_dim, projection_dim)
         self.dropout = nn.Dropout(dropout)
         self.layer_norm = nn.LayerNorm(projection_dim)
     
     def forward(self, x):
         projected = self.projection(x)
         x = self.gelu(projected)
         x = self.fc(x)
         x = self.dropout(x)
         x = x + projected
         x = self.layer_norm(x)
         return x

所以最后我们的CLIP模型就是这样:

 class CLIPModel(nn.Module):
     def __init__(
         self,
         temperature=CFG.temperature,
         image_embedding=CFG.image_embedding,
         text_embedding=CFG.text_embedding,
     ):
         super().__init__()
         self.image_encoder = ImageEncoder()
         self.text_encoder = TextEncoder()
         self.image_projection = ProjectionHead(embedding_dim=image_embedding)
         self.text_projection = ProjectionHead(embedding_dim=text_embedding)
         self.temperature = temperature
 
     def forward(self, batch):
         # Getting Image and Text Features
         image_features = self.image_encoder(batch["image"])
         text_features = self.text_encoder(
             input_ids=batch["input_ids"], attention_mask=batch["attention_mask"]
         )
         # Getting Image and Text Embeddings (with same dimension)
         image_embeddings = self.image_projection(image_features)
         text_embeddings = self.text_projection(text_features)
 
         # Calculating the Loss
         logits = (text_embeddings @ image_embeddings.T) / self.temperature
         images_similarity = image_embeddings @ image_embeddings.T
         texts_similarity = text_embeddings @ text_embeddings.T
         targets = F.softmax(
             (images_similarity + texts_similarity) / 2 * self.temperature, dim=-1
         )
         texts_loss = cross_entropy(logits, targets, reduction='none')
         images_loss = cross_entropy(logits.T, targets.T, reduction='none')
         loss =  (images_loss + texts_loss) / 2.0 # shape: (batch_size)
         return loss.mean()
 
 #这里还加了一个交叉熵函数
 def cross_entropy(preds, targets, reduction='none'):
     log_softmax = nn.LogSoftmax(dim=-1)
     loss = (-targets * log_softmax(preds)).sum(1)
     if reduction == "none":
         return loss
     elif reduction == "mean":
         return loss.mean()

这里需要说明下,CLIP使用 symmetric cross entropy 作为损失函数,可以降低噪音影响,提高模型鲁棒性,我们这里为了简单只是用cross entropy 。

我们可以进行测试:

 # A simple Example
 
 batch_size = 4
 dim = 256
 embeddings = torch.randn(batch_size, dim)
 out = embeddings @ embeddings.T
 print(F.softmax(out, dim=-1))

下一步就是训练了,有一些函数可以帮助我们加载训练和验证的dataloader

 def make_train_valid_dfs():
     dataframe = pd.read_csv(f"{CFG.captions_path}/captions.csv")
     max_id = dataframe["id"].max() + 1 if not CFG.debug else 100
     image_ids = np.arange(0, max_id)
     np.random.seed(42)
     valid_ids = np.random.choice(
         image_ids, size=int(0.2 * len(image_ids)), replace=False
     )
     train_ids = [id_ for id_ in image_ids if id_ not in valid_ids]
     train_dataframe = dataframe[dataframe["id"].isin(train_ids)].reset_index(drop=True)
     valid_dataframe = dataframe[dataframe["id"].isin(valid_ids)].reset_index(drop=True)
     return train_dataframe, valid_dataframe
 
 
 def build_loaders(dataframe, tokenizer, mode):
     transforms = get_transforms(mode=mode)
     dataset = CLIPDataset(
         dataframe["image"].values,
         dataframe["caption"].values,
         tokenizer=tokenizer,
         transforms=transforms,
     )
     dataloader = torch.utils.data.DataLoader(
         dataset,
         batch_size=CFG.batch_size,
         num_workers=CFG.num_workers,
         shuffle=True if mode == "train" else False,
     )
     return dataloader

然后就是训练和评估

 def train_epoch(model, train_loader, optimizer, lr_scheduler, step):
     loss_meter = AvgMeter()
     tqdm_object = tqdm(train_loader, total=len(train_loader))
     for batch in tqdm_object:
         batch = {k: v.to(CFG.device) for k, v in batch.items() if k != "caption"}
         loss = model(batch)
         optimizer.zero_grad()
         loss.backward()
         optimizer.step()
         if step == "batch":
             lr_scheduler.step()
 
         count = batch["image"].size(0)
         loss_meter.update(loss.item(), count)
 
         tqdm_object.set_postfix(train_loss=loss_meter.avg, lr=get_lr(optimizer))
     return loss_meter
 
 
 def valid_epoch(model, valid_loader):
     loss_meter = AvgMeter()
 
     tqdm_object = tqdm(valid_loader, total=len(valid_loader))
     for batch in tqdm_object:
         batch = {k: v.to(CFG.device) for k, v in batch.items() if k != "caption"}
         loss = model(batch)
 
         count = batch["image"].size(0)
         loss_meter.update(loss.item(), count)
 
         tqdm_object.set_postfix(valid_loss=loss_meter.avg)
     return loss_meter

最后整合起来就是全部流程

 def main():
     train_df, valid_df = make_train_valid_dfs()
     tokenizer = DistilBertTokenizer.from_pretrained(CFG.text_tokenizer)
     train_loader = build_loaders(train_df, tokenizer, mode="train")
     valid_loader = build_loaders(valid_df, tokenizer, mode="valid")
 
 
     model = CLIPModel().to(CFG.device)
     params = [
         {"params": model.image_encoder.parameters(), "lr": CFG.image_encoder_lr},
         {"params": model.text_encoder.parameters(), "lr": CFG.text_encoder_lr},
         {"params": itertools.chain(
             model.image_projection.parameters(), model.text_projection.parameters()
         ), "lr": CFG.head_lr, "weight_decay": CFG.weight_decay}
     ]
     optimizer = torch.optim.AdamW(params, weight_decay=0.)
     lr_scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(
         optimizer, mode="min", patience=CFG.patience, factor=CFG.factor
     )
     step = "epoch"
 
     best_loss = float('inf')
     for epoch in range(CFG.epochs):
         print(f"Epoch: {epoch + 1}")
         model.train()
         train_loss = train_epoch(model, train_loader, optimizer, lr_scheduler, step)
         model.eval()
         with torch.no_grad():
             valid_loss = valid_epoch(model, valid_loader)
         
         if valid_loss.avg < best_loss:
             best_loss = valid_loss.avg
             torch.save(model.state_dict(), "best.pt")
             print("Saved Best Model!")
         
         lr_scheduler.step(valid_loss.avg)

应用:获取图像嵌入并找到匹配。

我们训练完成后如何实际应用呢?我们需要编写一个函数加载训练后的模型,为其提供验证集中的图像,并返回形状(valid_set_size, 256)和模型本身的image_embeddings。

 def get_image_embeddings(valid_df, model_path):
     tokenizer = DistilBertTokenizer.from_pretrained(CFG.text_tokenizer)
     valid_loader = build_loaders(valid_df, tokenizer, mode="valid")
     
     model = CLIPModel().to(CFG.device)
     model.load_state_dict(torch.load(model_path, map_location=CFG.device))
     model.eval()
     
     valid_image_embeddings = []
     with torch.no_grad():
         for batch in tqdm(valid_loader):
             image_features = model.image_encoder(batch["image"].to(CFG.device))
             image_embeddings = model.image_projection(image_features)
             valid_image_embeddings.append(image_embeddings)
     return model, torch.cat(valid_image_embeddings)
 _, valid_df = make_train_valid_dfs()
 model, image_embeddings = get_image_embeddings(valid_df, "best.pt")
 
 def find_matches(model, image_embeddings, query, image_filenames, n=9):
     tokenizer = DistilBertTokenizer.from_pretrained(CFG.text_tokenizer)
     encoded_query = tokenizer([query])
     batch = {
         key: torch.tensor(values).to(CFG.device)
         for key, values in encoded_query.items()
     }
     with torch.no_grad():
         text_features = model.text_encoder(
             input_ids=batch["input_ids"], attention_mask=batch["attention_mask"]
         )
         text_embeddings = model.text_projection(text_features)
     
     image_embeddings_n = F.normalize(image_embeddings, p=2, dim=-1)
     text_embeddings_n = F.normalize(text_embeddings, p=2, dim=-1)
     dot_similarity = text_embeddings_n @ image_embeddings_n.T
     
     values, indices = torch.topk(dot_similarity.squeeze(0), n * 5)
     matches = [image_filenames[idx] for idx in indices[::5]]
     
     _, axes = plt.subplots(3, 3, figsize=(10, 10))
     for match, ax in zip(matches, axes.flatten()):
         image = cv2.imread(f"{CFG.image_path}/{match}")
         image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
         ax.imshow(image)
         ax.axis("off")
     
     plt.show()

调用方法如下:

 find_matches(model, 
              image_embeddings,
              query="one dog sitting on the grass",
              image_filenames=valid_df['image'].values,
              n=9)

可以看到我们自定义效果还是不错的(但是图里面有个猫,哈)。也就是说CLIP这种方法在小数据集上自定义也是可行的。

以下是本文的代码和数据集:

https://avoid.overfit.cn/post/25295aa8daee45fc8336b2e86a29106a

作者:Jyoti Dabass, Ph.D

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/999726.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

阅读分享--重读Youtube深度学习推荐系统论文,字字珠玑,惊为神文

重读Youtube深度学习推荐系统论文,字字珠玑,惊为神文 https://zhuanlan.zhihu.com/p/52169807 废话不多说,下面就跟大家分享一下两次拜读这篇论文的不同体验和收获。 第一遍读这篇论文的时候,我想所有人都是冲着算法的架构去的,在深度学习推荐系统已经成为各大公司“基本…

Linux下安装部署Tomcat8_图解_保姆级教程

检查工作 tomcat依赖于JDK ,所以我们要先检查 Linux 下是否安装了JDK并配置了环境变量 java -version // 查看JDK版本号如需安装JDK,请参考: Linux下安装JDK,巨详细 1.将安装包放到自己所建立的文件夹之下 我这里采用的软件是Xftp,创建一个文件夹,将Xftp的路径指向刚刚创…

一站式开源持续测试平台 MerterSphere 之测试跟踪操作详解

一、MeterSphere平台介绍 MeterSphere是一站式的开源持续测试平台&#xff0c;遵循 GPL v3 开源许可协议&#xff0c;涵盖测试跟踪、接口测试、UI 测试和性能测试等功能&#xff0c;全面兼容JMeter、Selenium 等主流开源标准&#xff0c;有效助力开发和测试团队充分利用云弹性…

这一次,大模型颠覆广告行业!

本文源自&#xff1a;量子位 百度用大模型重构一切&#xff0c;包括现金奶牛业务。 就在刚刚&#xff0c;百度营销官宣接入文心一言&#xff0c;推出新一代营销平台轻舸&#xff0c;也是全球首个AI Native的营销平台。 这么说吧&#xff0c;是有点颠覆互联网广告投放模式内味…

网络安全(红客)自学

前言 1.这是一条坚持的道路,三分钟的热情可以放弃往下看了. 2.多练多想,不要离开了教程什么都不会了.最好看完教程自己独立完成技术方面的开发. 3.有时多 google,baidu,我们往往都遇不到好心的大神,谁会无聊天天给你做解答. 4.遇到实在搞不懂的,可以先放放,以后再来解决. …

电脑文件一团乱?试试这个高效率的管理软件

如果你经常被电脑文件管理搞得一团乱&#xff0c;不妨试试这款高效率的文件管理软件——固乔文件管家。下面是如何使用这款软件进行文件管理的详细步骤。 首先&#xff0c;打开浏览器并搜索“固乔文件管家”这款软件。搜索到后&#xff0c;点击下载并安装。安装完成后&#xff…

2023-亲测有效-git clone失败怎么办?用代理?加git?

git 克隆不下来&#xff0c;超时 用以下格式&#xff1a; git clone https://ghproxy.com/https://github.com/Tencent/ncnn.git 你的网站前面加上 https://ghproxy.com/ 刷的一下就下完了&#xff01;&#xff01;

【股票价格预测】基于改进莱维飞行和混沌映射的粒子群优化BP神经网络预测股票价格研究(Matlab代码实现)

&#x1f4a5;&#x1f4a5;&#x1f49e;&#x1f49e;欢迎来到本博客❤️❤️&#x1f4a5;&#x1f4a5; &#x1f3c6;博主优势&#xff1a;&#x1f31e;&#x1f31e;&#x1f31e;博客内容尽量做到思维缜密&#xff0c;逻辑清晰&#xff0c;为了方便读者。 ⛳️座右铭&a…

面试项目-黑马头条-项目介绍

1 项目介绍 B站视频黑马头条视频学习总结&#xff0c;侵权请联系删除 1.1 项目背景 随着智能手机的普及&#xff0c;人们更加习惯于通过手机来看新闻。由于生活节奏的加快&#xff0c;很多人只能利用碎片时间来获取信息&#xff0c;因此&#xff0c;对于移动资讯客户端的需求也…

薪资17K+需要什么水平?98年测试工程师面试实录…

我的情况 大概介绍一下个人情况&#xff0c;男&#xff0c;本科&#xff0c;三年多测试工作经验&#xff0c;懂python&#xff0c;会写脚本&#xff0c;会selenium&#xff0c;会性能&#xff0c;然而到今天都没有收到一份offer&#xff01;从年后就开始准备简历&#xff0c;年…

从官方文档看Redis

一.核心能力 Redis 1.In-memory data structures 内存数据结构 Redis以"键值对" 的方式来存储数据, 是一种"非关系型数据库" ps: MySQL以"表"的方式来存储数据, 是一种"关系型数据库" 2.Programmability 可编程性 Redis可以用脚本…

Eclipse 安装串口终端工具

Eclipse已集成串口终端显示&#xff0c;只需要我们自己下载安装即可使用。使用SSH连接也差不多。 查看eclipse版本信息 help->About Eclipse 查看version&#xff0c;我的是4.7.3a&#xff0c;记住代号&#xff0c;我的是“Oxygen”,下面有用。 安装eclipse自带的“Termin…

【HELLO NEW WORLD】一封来自开放自动化时代的邀请函

施耐德电气开放自动化平台&#xff0c;迈向开放、高效与韧性、可持续、以人为本的未来工业。 HELLO WORLD 是人类在信息世界开启的第一行 也是我们走进自动化领域迎来的第一句问候 如今 面临向数字化与自动化加速转型的新变局 工业领域迫切地需要一场变革 走向更加高效、…

一名IT重装操作系统后的安装环境历程

1、安装JDK&#xff0c;配置环境变量&#xff08;我一般默认安装&#xff0c;避免后期一些不必要的问题&#xff09;。 下载链接 个人安装包可从下方下载&#xff1a; 链接&#xff1a;https://pan.baidu.com/s/1XIsjCQ2Y-r1m9H8MBlsILQ?pwd6ekm 提取码&#xff1a;6ekm …

惊!这么好用的纯html网页模板可还行?偷偷拿去做作业真是绝绝子!!

在这个万物vue的年代&#xff0c;网页设计越来越框架化。 上网搜个资料学习学习吧&#xff0c;咵咵咵&#xff0c;“游泳健身&#xff0c;vue了解一下” 我只是想简单地学个html&#xff0c;js啊&#xff01;怎么就这么复杂&#xff01; 曾几何时&#xff0c;在网上找个网页…

matlab rbf手写

clc;clear;format long; trainD0:0.1:10; outDsin(trainD).*trainD; outDoutD; dnumlength(trainD); %初始化参数学习率 lr_w00.1;%权值 lr_c00.1;%中心点 lr_sigma00.1;%神经节点的方差参数 iters10000;%最大迭代次数 minE1e-2;%误差小于他就停止 cnum150;%神经元个数 clinspa…

山西电力市场日前价格预测【2023-09-12】

日前价格预测 预测说明&#xff1a; 如上图所示&#xff0c;预测明日&#xff08;2023-09-12&#xff09;山西电力市场全天平均日前电价为344.48元/MWh。其中&#xff0c;最高日前电价为405.81元/MWh&#xff0c;预计出现在19: 15。最低日前电价为302.50元/MWh&#xff0c;预计…

电脑文件批量重命名:高效操作技巧

随着时间的推移&#xff0c;我们积累的文件和文件夹数量越来越多&#xff0c;需要对它们进行合理的命名和管理&#xff0c;以便更方便地查找和利用。而文件批量重命名功能可以帮助我们更高效地管理文件夹。下面介绍五种方式&#xff0c;帮助你更好地利用文件批量重命名工具&…

02_Flutter自定义Sliver组件实现分组列表吸顶效果

02_Flutter自定义Sliver组件实现分组列表吸顶效果 一.先上效果图 二.列表布局实现 比较简单&#xff0c;直接上代码&#xff0c;主要使用CustomScrollView和SliverToBoxAdapter实现 _buildSection(String title) {return SliverToBoxAdapter(child: RepaintBoundary(child: C…

Doodles版洞洞鞋3天售罄 蓝筹NFT卖货自救

加密资产市场仍在寒冬&#xff0c;上个牛市爆火的NFT正在经历第一次深熊&#xff0c;无数NFT项目遭市场清洗&#xff0c;玩家争相出逃。市场冷清到惨烈&#xff0c;哪怕是价值认可度最高的蓝筹NFT&#xff0c;也未能逃过持续暴跌的窘境&#xff0c;一些项目开始自救。 近期&am…