自监督去噪:Noise2Self原理分析及实现 (Pytorch)

news2024/9/17 8:57:07

在这里插入图片描述

文章地址:https://arxiv.org/abs/1901.11365

代码地址: https://github.com/czbiohub-sf/noise2self

要点
  Noise2Self方法不需要信号先验信息、噪声估计信息和干净的训练数据。唯一的假设就是噪声在测量的不同维度上表现出的统计独立性,而真实信号表现出一定的相关性。Noiser2Self根据J-invariant提出了一种噪声校正的方案,可以应用到一系列的去噪方法之中,提高这些去噪方法的效果。

文章目录

      • 1. 方法原理
      • 2. 实验结果
        • 2.1 传统校正方法
        • 2.2 高斯噪声
        • 2.3 不同网络结构对比
      • 3. 代码实现
        • 3.1 J-invariant + 传统方法
        • 3.1 J-invariant + 神经网络
      • 4. 总结


1. 方法原理

如果所研究对象的空间的“潜在维度”远低于测量的维度,则可以隐式地学习该结构,对测量进行降噪,并在没有任何先验知识的情况下恢复信号,信号或噪声。

传统方法问题

  • 需要对噪声模式进行估计(如高斯噪声、结构性噪声),那么这些方法的效果就受限于对噪声模式的估计。
  • 需要对信号数据的结构有先验估计,但是这会限制去噪方法迁移到其他数据集。
  • 需要校准,因为平滑度、自相似性或矩阵的秩等超参数对去噪方法也会有影响

J-invariant 定义

假设 j ∈ J j \in J jJ, J J J是m维空间, 存在一个函数变换 f ( x ) J : R m ⇒ R m f(x)_J: R^m \Rightarrow R^m f(x)J:RmRm。如果这个变换过程不依赖于输入的 x J x_J xJ,那么称这个函数是具有J不变性质。

换个能看懂的说法:信号本身是相关的,假设噪声是互不相关的(条件独立的),那么我们用一个方法对这个噪声图片的部分数据进行处理,这个处理结果应该是和处理全部数据效果相同的,也就是使用部分维度信息达到恢复全局的效果。(需要强调的是我自己这里也没有理解特别透彻,如果有错误可以提出大家讨论)

假设 x x x(噪声图片) 是 y y y(干净图片)的无偏估计( E [ x ∣ y ] = y E[x|y] = y E[xy]=y), 噪声是整个域内是条件独立的,那么有:
E ∣ ∣ f ( x ) − x ∣ ∣ 2 2 = E ∣ ∣ f ( x ) − y ∣ ∣ 2 2 + E ∣ ∣ x − y ∣ ∣ 2 2 E||f(x) - x||_2^2 = E||f(x) - y||_2^2 + E||x - y||_2^2 E∣∣f(x)x22=E∣∣f(x)y22+E∣∣xy22

可以看到这里的无监督学习的损失等于 传统的监督学习的损失 加上噪声带来的偏差。

用J不变性描述一下 Noise2Noise就变为
如果现在有两个观测的噪声数据 x 1 = y + n 1 x_1 = y + n_1 x1=y+n1 , x 2 = y + n 2 x_2 = y + n_2 x2=y+n2
观测组合: x = ( x 1 , x 2 ) x = (x_1,x_2) x=(x1,x2)
信号组合 y = ( y , y ) ∈ R 2 m y = (y,y) \in R^{2m} y=(y,y)R2m
如果存在 J = { J 1 , J 2 } = { { 1 , . . . , m } , { m + 1 , . . . , 2 m } } J = \{J_1,J_2\} = \{\{1,...,m\},\{m+1,...,2m\}\} J={J1,J2}={{1,...,m},{m+1,...,2m}},那么有
f J ∗ ( x ) J 2 = E [ y ∣ x 1 ] f_{J}^*(x)_{J2} = E[y|x_1] fJ(x)J2=E[yx1]

就个人理解:J-不变性就是一个假设:如果噪声是条件独立的,那么监督去噪等价于无监督去噪加上一个噪声的偏差影响。

2. 实验结果

2.1 传统校正方法

首先将J不变性应用到 传统方法中:
传统的 “median filter”是将半径范围内所有像素的点都替换为中值
这里对比的是一种“donut filter”中值滤波方法:用中值替换除了中心像素的所有位置

那么“median filter”和“donut”甜甜圈模式的滤波器,其自监督的损失分别为
∣ ∣ g r ( x ) − x ∣ ∣ 2 ||g_r(x) - x||^2 ∣∣gr(x)x2

∣ ∣ f r ( x ) − x ∣ ∣ 2 ||f_r(x) - x||^2 ∣∣fr(x)x2

用图绘制出来:

从上图可以看出:median滤波器监督学习的损失随着半径的增加而线性增加,而donut滤波器在r = 3的时候其损失有一个最佳值。蓝色实线和蓝色虚线的垂直距离其实表征的是噪声带来的偏差,那么我们就发现了对于传统的滤波器,我们只能够更改输入来进行调整滤波效果,但是对于donut这类具有J-invariant性质的滤波器,我们可以通过一些原则来调整滤波效果(比如这里的距离r)

那么就可以给定一个比较通用的新滤波器形式了
f θ ( x ) J : = g θ ( 1 J . s ( x ) + 1 J c . x ) J f_{\theta}(x)_J := g_{\theta}(1_J . s(x) + 1_{Jc} . x)_J fθ(x)J:=gθ(1J.s(x)+1Jc.x)J

这里的 g θ g_{\theta} gθ表示传统的滤波其, s ( x ) s(x) s(x)表示将一些像素替换为周围其他像素的值/均值的一个操作。

个人理解:和Noise2Void那种盲点去噪的感觉相同,都是将输入的某些值进行替换,然后恢复那个点的信息。如果将这种方法应用到传统方法中可以帮我们找到最佳的滤波参数。

2.2 高斯噪声

2.3 不同网络结构对比

3. 代码实现

相关代码参考: https://github.com/czbiohub-sf/noise2self

3.1 J-invariant + 传统方法

这里以使用 J-invariant 到 中值滤波为例


加载相关库和数据

import sys
sys.path.append("..")
import numpy as np
import matplotlib.pyplot as plt
from skimage.morphology import disk
from skimage.filters import gaussian, median
from skimage import data, img_as_float, img_as_ubyte
from skimage.color import gray2rgb
from skimage.util import random_noise
from skimage.metrics import structural_similarity as ssim
from skimage.metrics import peak_signal_noise_ratio as psnr
from skimage.metrics import mean_squared_error as mse
from util import plot_grid, plot_images, expand

# 加载原始数据
plt.rc('figure', figsize = (5,5))
show = lambda x: plt.imshow(x, cmap=plt.cm.gray)
image = data.camera()
show(image)
plt.show()

# 加噪原始数据
np.random.seed(3)
noisy_image = img_as_ubyte(random_noise(image, mode = 'gaussian', var=0.01))
show(noisy_image)
plt.show()

在这里插入图片描述

定义中值滤波和donut中值滤波方法(引入J-invariant)

def mask_center(x):
    x[len(x)//2,len(x)//2] = 0
    return x

plot_images([1-disk(4), 1-mask_center(disk(4))])

在这里插入图片描述
滤波并进行对比

radii = range(1, 7)
mask_med = np.array([median(noisy_image, mask_center(disk(i))) for i in radii])
med = np.array([median(noisy_image, disk(i)) for i in radii])

plt.figure(figsize=(18,6))
for i in range(1,7):
    plt.subplot(2,6,i)
    show(mask_med[i-1])
    plt.title("r={}".format(radii[i-1]))
    if i ==1:
        plt.ylabel("donut")

for i in range(1,7):
    plt.subplot(2,6,6+i)
    show(med[i-1])
    if i ==1:
        plt.ylabel("median filter")
    
plt.show()

在这里插入图片描述

统计损失及相关参考指标

def stats(im_list, noisy_img, img):
    img = img_as_float(img)
    noisy_img = img_as_float(noisy_img)
    im_list = [img_as_float(x) for x in im_list]
    
    loss = [mse(x, noisy_img) for x in im_list]
    mse_gt = [mse(x, img) for x in im_list]
    psnr_gt = [psnr(x, img) for x in im_list]
    
    return loss, mse_gt, psnr_gt

loss_med, mse_med, psnr_med = stats(med, noisy_image, image)
loss_mask_med, mse_mask_med, psnr_mask_med = stats(mask_med, noisy_image, image)
opt = radii[np.argmin(loss_mask_med)]


plt.figure(figsize=(7,5))

plt.plot(radii, loss_mask_med, label = 'self-supervised, donut median', color = 'C0')
plt.plot(radii, loss_med, label = 'self-supervised, ordinary median', color = 'C1')

plt.axvline(radii[np.argmin(loss_mask_med)], color='k', linestyle='--')
plt.title('Calibrating a Median Filter')

plt.plot(radii, mse_mask_med, label = 'reconstruction error, donut median', color = 'C0', linestyle='--')
plt.plot(radii, mse_med, label = 'reconstruction error, ordinary median', color = 'C1', linestyle='--')
plt.ylabel('MSE')
plt.xlabel('Radius of Median Filter')

plt.yticks([0.002, 0.012])
plt.ylim(0, 0.0143)
plt.legend(loc='center right')
plt.show()

在这里插入图片描述

加入J-invariant之后可以帮助我们找到最佳的滤波参数(此处r = 3)

3.1 J-invariant + 神经网络

加载库及数据

from util import show, plot_images, plot_tensors
from torchvision.datasets import MNIST
from torchvision import transforms
from torch.utils.data import Dataset

mnist_train = MNIST(root='/data/mnist/', download = True,
                    transform = transforms.Compose([
                        transforms.ToTensor(),
                    ]), train = True)

mnist_test = MNIST('/data/mnist/', download = True,
                    transform = transforms.Compose([
                        transforms.ToTensor(),
                    ]), train = False)

定义加噪方法

from torch import randn
def add_noise(img):
    return img + randn(img.size())*0.4

class SyntheticNoiseDataset(Dataset):
    def __init__(self, data, mode='train'):
        self.mode = mode
        self.data = data
        
    def __len__(self):
        return len(self.data)
    
    def __getitem__(self, index):
        img = self.data[index][0]
        return add_noise(img), img
noisy_mnist_train = SyntheticNoiseDataset(mnist_train, 'train')
noisy_mnist_test = SyntheticNoiseDataset(mnist_test, 'test')
noisy, clean = noisy_mnist_train[0]
plot_tensors([noisy[0], clean[0]], ['Noisy Image', 'Clean Image'])

在这里插入图片描述

加mask也就是加盲点,需要恢复的也是这些盲点的信息

class Masker():
    """Object for masking and demasking"""

    def __init__(self, width=3, mode='zero', infer_single_pass=False, include_mask_as_input=False):
        self.grid_size = width
        self.n_masks = width ** 2

        self.mode = mode
        self.infer_single_pass = infer_single_pass
        self.include_mask_as_input = include_mask_as_input

    def mask(self, X, i):

        phasex = i % self.grid_size
        phasey = (i // self.grid_size) % self.grid_size
        mask = pixel_grid_mask(X[0, 0].shape, self.grid_size, phasex, phasey)
        mask = mask.to(X.device)

        mask_inv = torch.ones(mask.shape).to(X.device) - mask

        if self.mode == 'interpolate':
            masked = interpolate_mask(X, mask, mask_inv)
        elif self.mode == 'zero':
            masked = X * mask_inv
        else:
            raise NotImplementedError
            
        if self.include_mask_as_input:
            net_input = torch.cat((masked, mask.repeat(X.shape[0], 1, 1, 1)), dim=1)
        else:
            net_input = masked

        return net_input, mask

    def __len__(self):
        return self.n_masks

    def infer_full_image(self, X, model):

        if self.infer_single_pass:
            if self.include_mask_as_input:
                net_input = torch.cat((X, torch.zeros(X[:, 0:1].shape).to(X.device)), dim=1)
            else:
                net_input = X
            net_output = model(net_input)
            return net_output

        else:
            net_input, mask = self.mask(X, 0)
            net_output = model(net_input)

            acc_tensor = torch.zeros(net_output.shape).cpu()

            for i in range(self.n_masks):
                net_input, mask = self.mask(X, i)
                net_output = model(net_input)
                acc_tensor = acc_tensor + (net_output * mask).cpu()

            return acc_tensor


def pixel_grid_mask(shape, patch_size, phase_x, phase_y):
    A = torch.zeros(shape[-2:])
    for i in range(shape[-2]):
        for j in range(shape[-1]):
            if (i % patch_size == phase_x and j % patch_size == phase_y):
                A[i, j] = 1
    return torch.Tensor(A)


def interpolate_mask(tensor, mask, mask_inv):
    device = tensor.device

    mask = mask.to(device)

    kernel = np.array([[0.5, 1.0, 0.5], [1.0, 0.0, 1.0], (0.5, 1.0, 0.5)])
    kernel = kernel[np.newaxis, np.newaxis, :, :]
    kernel = torch.Tensor(kernel).to(device)
    kernel = kernel / kernel.sum()

    filtered_tensor = torch.nn.functional.conv2d(tensor, kernel, stride=1, padding=1)

    return filtered_tensor * mask + tensor * mask_inv

masker = Masker(width = 4, mode='interpolate')
net_input, mask = masker.mask(noisy.unsqueeze(0), 0)
plot_tensors([mask, noisy[0], net_input[0], net_input[0] - noisy[0]],
            ["Mask", "Noisy Image", "Neural Net Input", "Difference"])

在这里插入图片描述

加载网络模型和进行训练

import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.nn import MSELoss
from torch.optim import Adam
from torch.utils.data import DataLoader
from tqdm import tqdm
from models.modules import ConvBlock


class BabyUnet(nn.Module):
    def __init__(self, n_channel_in=1, n_channel_out=1, width=16):
        super(BabyUnet, self).__init__()
        self.pool1 = nn.MaxPool2d(kernel_size=2)
        self.pool2 = nn.MaxPool2d(kernel_size=2)

        self.up1 = lambda x: F.interpolate(x, mode='bilinear', scale_factor=2, align_corners=False)
        self.up2 = lambda x: F.interpolate(x, mode='bilinear', scale_factor=2, align_corners=False)

        self.conv1 = ConvBlock(n_channel_in, width)
        self.conv2 = ConvBlock(width, 2*width)

        self.conv3 = ConvBlock(2*width, 2*width)

        self.conv4 = ConvBlock(4*width, 2*width)
        self.conv5 = ConvBlock(3*width, width)

        self.conv6 = nn.Conv2d(width, n_channel_out, 1)

    def forward(self, x):
        c1 = self.conv1(x)
        x = self.pool1(c1)
        c2 = self.conv2(x)
        x = self.pool2(c2)
        x = self.conv3(x)

        x = self.up1(x)
        x = torch.cat([x, c2], 1)
        x = self.conv4(x)
        x = self.up2(x)
        x = torch.cat([x, c1], 1)
        x = self.conv5(x)
        x = self.conv6(x)
        return x
model = BabyUnet()
loss_function = MSELoss()
optimizer = Adam(model.parameters(), lr=0.001)

data_loader = DataLoader(noisy_mnist_train, batch_size=32, shuffle=True)

pbar = tqdm(data_loader)

for i, batch in enumerate(pbar):
    noisy_images, clean_images = batch
    
    net_input, mask = masker.mask(noisy_images, i)
    net_output = model(net_input)
    
    loss = loss_function(net_output*mask, noisy_images*mask)
    
    optimizer.zero_grad()

    loss.backward()
    
    optimizer.step()
    
    pbar.set_description("Iter:{},loss:{}".format(i,loss.item()))
    # if i % 10 == 0:
    #     print("Loss (", i, "): \t", round(loss.item(), 4))
        
    if i == 100:
        break

测试训练效果

test_data_loader = DataLoader(noisy_mnist_test,
                                              batch_size=32,
                                              shuffle=False,
                                              num_workers=3)
i, test_batch = next(enumerate(test_data_loader))
noisy, clean = test_batch
simple_output = model(noisy)
invariant_output = masker.infer_full_image(noisy, model)
idx = 3
plot_tensors([clean[idx], noisy[idx], simple_output[idx], invariant_output[idx]],
            ["Ground Truth", "Noisy Image", "Single Pass Inference", "J-Invariant Inference"])

在这里插入图片描述

盲点网络训练后使用不同的输入(加盲点或者不加)得到的效果有些许差别,但是整体的去噪效果还可以。


4. 总结

  1. 引入J-invariant的概念到去噪工作之中,通过测试对比发现这种方法的自监督比传统方法有更好的效果,可以帮助传统方法寻找最佳的调整参数
  2. J-invariant的思路可以应用到传统去噪方法中或者先前的无监督、自监督学习工作之中,提高效果。(对比了Noise2Noiser和Noiser2Void方法)
  3. 和Noise2Void有异曲同工之妙,分析原理都是使用盲点网络的思想对输入数据进行mask,然后使用网络恢复这些盲点位置的信息。所以也存在和盲点网络相同的问题
    • 损失了盲点位置的信息
    • 盲点网络的假设:噪声是条件不相关的,信号是相关的;对于结构性的噪声的效果会较差。
    • 噪声零均值假设等假设限制了该方法应用到实际数据之中。

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

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

相关文章

亚马逊云科技七项生成式AI新产品生成式AI,为用户解决数据滞后等难题

7月27日,亚马逊云科技在纽约峰会上一连发布了七项生成式AI创新,涵盖了从底层硬件到工具、软件、再到生态的全方位更新,成为它在该领域迄今最全面的一次升级展示,同时也进一步降低了生成式AI的使用门槛。 亚马逊云科技凭借自身端到…

vue实现全屏、退出全屏方法

有效解决问题 : Failed to execute ‘exitFullscreen‘ on ‘Document‘: Document not active 在未全屏的情况下触发exitFullscreen&#xff0c;会报这个错。 <el-button class"screen" click"toggleFullscreen">全屏</el-button> fullscre…

Unity 编辑器选择器工具类Selection 常用函数和用法

Unity 编辑器选择器工具类Selection 常用函数和用法 点击封面跳转下载页面 简介 在Unity中&#xff0c;Selection类是一个非常有用的工具类&#xff0c;它提供了许多函数和属性&#xff0c;用于操作和管理编辑器中的选择对象。本文将介绍Selection类的常用函数和用法&#xff…

Redis面试题2

Redis面试题-2 10、统计高并发网站每个网页每天的 UV 数据&#xff0c;结合Redis你会如何实现&#xff1f; 选用方案&#xff1a;HyperLogLog 如果统计 PV 那非常好办&#xff0c;给每个网页一个独立的 Redis 计数器就可以了&#xff0c;这个计数器的 key 后缀加上当天的日期…

loguru 日志库

1 下载地址 loguru PyPI 2 安装 pip install loguru 3 简单使用 from loguru import logger# 当前目录下创建 log 文件夹&#xff0c;保留 3 天 logger.add("./Log/INFO_{time:YYYY-MM-DD}.log", encoding"utf-8", retention"3 day")logger.…

JUC并发编程(一)JUC工具使用

文章目录 Synchronizedsynchronized解决 Lock锁synchronized锁与Lock锁的区别 生成者消费者问题synchronized实现lock版实现- conditioncondition实现精准通知唤醒 CallableJUC常用辅助类CountDownLatch&#xff08;倒计时器&#xff09;CyclicBarrier&#xff08;循环栅栏&…

Find My资讯|AirTag 2或将在明年底量产,苹果Find My应用全面

AirTag 2021 年和 2022 年出货量预计在 2000 万个和 3500 万个。如果 AirTag 出货量继续增长&#xff0c;苹果大概率会开发第二代产品。郭明錤现更新了其预测报告&#xff0c;他表示&#xff0c;苹果 AirTag 2 可能会在明年第四季度量产。 AirTag 是用于固定在背包、行李和其…

Typora未保存报错闪退

Typora未保存报错闪退 在使用Typora编写笔记时&#xff0c;可能会遇到没有保存就退出的情况&#xff0c;也可能软件突然崩溃导致之前的笔记没有保存&#xff0c;实际上Typora会自动保存记录在本地文件中&#xff0c;接下来我们进行恢复。 点击左上角文件标签&#xff0c;进入…

《Gradle构建脚本的基本结构和语法》

&#x1f337;&#x1f341; 博主猫头虎 带您 Go to New World.✨&#x1f341; &#x1f984; 博客首页——猫头虎的博客&#x1f390; &#x1f433;《面试题大全专栏》 文章图文并茂&#x1f995;生动形象&#x1f996;简单易学&#xff01;欢迎大家来踩踩~&#x1f33a; &a…

【Python机器学习】实验08 决策树

文章目录 决策树1 创建数据2 定义香农信息熵3 条件熵4 信息增益5 计算所有特征的信息增益&#xff0c;选择最优最大信息增益的特征返回6 利用ID3算法生成决策树7 利用数据构造一颗决策树Scikit-learn实例决策树分类决策树回归Scikit-learn 的决策树参数决策树调参 实验1 通过sk…

学习笔记|简单分享一下自建Gravatar镜像

目录 前言 Gravatar 使用 思路 操作 步骤一&#xff1a;注册或登录华为云 步骤二&#xff1a;创建委托账号 步骤三&#xff1a;创建OBS桶 步骤四&#xff1a;数据回源配置 步骤五&#xff1a;配置生命周期规则 步骤六&#xff1a;绑定自定义域名 步骤七&#xff1a…

学习Android嵌入式需要做哪些准备?

首先嵌入式系统可以分为四个层次&#xff1a;硬件层、驱动层、系统层和应用层&#xff0c;其中每一个层次都会有一类专业的学生适合深入学习。硬件层就适合电子、通信、机电等学生&#xff0c;驱动层因为比较难&#xff0c;按我的理解都是需要从硬件层和应用层过渡。而应用层就…

LNMP原理及安装

LNMP: 目前成熟的企业网站的应用模式之一&#xff0c;指的是一套协同工作的系统和相关软件能够提供静态页面服务&#xff0c;也可以提供动态web服务 LNMP的组成: L &#xff1a;表示linux系统&#xff0c;操作系统N &#xff1a;表示nginx网站服务&#xff0c;前端&#xf…

项目运行时出现:“listen EADDRINUSE: address already in use :::3002“

listen EADDRINUSE: address already in use :::3002 解决&#xff1a;查找对应 pid&#xff1a; netstat -ano PS C:\Users\admin\Desktop\chatgpt-web\service> netstat -ano 控制台输入&#xff1a;taskkill -f /pid 25420&#xff08;抓图是别的进程&#xff09; 结…

08. 容器间通信

目录 1、前言 2、容器间通信 2.1、通过IP地址进行通信 2.2、通过DNS Server进行通信 2.3、通过Joined方式通信 3、容器跨节点通信 3.1、通过容器在宿主机上的端口映射实现 3.2、通过Docker Overlay网络实现 4、小结 1、前言 上一篇《07.Docker网络通信模式》我们初步认…

LeetCode--HOT100题(17)

目录 题目描述&#xff1a;41. 缺失的第一个正数&#xff08;困难&#xff09;题目接口解题思路代码 PS: 题目描述&#xff1a;41. 缺失的第一个正数&#xff08;困难&#xff09; 给你一个未排序的整数数组 nums &#xff0c;请你找出其中没有出现的最小的正整数。 请你实现时…

harbor搭建

回到目录 Harbor 是 VMware 公司开源的企业级 Docker Registry 项目&#xff0c;其目标是帮助用户迅速搭建一个企业级的 Docker Registry 服务 通俗的讲&#xff0c;harbor是一个私人镜像存储服务器 1 下载安装 进入官网&#xff0c;下载一个离线安装包,harbor官网下载 这…

命令模式 Command Pattern 《游戏设计模式》学习笔记

对于一般的按键输入&#xff0c;我们通常这么做&#xff0c;直接if按了什么键&#xff0c;就执行相应的操作 在这里我们是将用户的输入和程序行为硬编码在一起&#xff0c;这是我们很自然就想到的最快的做法。 但是如果这是一个大型游戏&#xff0c;往往我们需要实现一个按键…

tinkerCAD案例:33. 在 Tinkercad 中使用标尺工具

在 Tinkercad 中使用标尺工具 Using the Ruler Tool in Tinkercad 在 Tinkercad 中使用标尺工具 Introduction: Using the Ruler Tool in Tinkercad 简介&#xff1a;在 Tinkercad 中使用标尺工具 For those that may not know Tinkercad is a web based 3D modeling tool.…

vue 浏览器右侧可拖拽小组件

目录 0. 使用场景 1. 动图示例 2. 实现方式 2.1 创建drag.js 2.2 使用v-drag 3. 结尾 0. 使用场景 很多网页在浏览器右侧有"导航"或者“智能助手”的悬浮小气泡框&#xff0c;比如我们的csdn☞ 作为页面友好型的引导标注&#xff0c;某些场景下这些小气泡可以…