2024年7月


凯撒密码(英语:Caesar cipher),或称凯撒加密、凯撒变换、变换加密,是一种最简单且最广为人知的加密技术。 凯撒密码是
一种替换加密
技术,明文中的所有字母都在字母表上向后(或向前)按照一个固定数目进行偏移后被替换成密文。 例如,当偏移量是3的时候,所有的字母被替换,比如A将被替换成D,B变成E,以此类推。

无规律的加密过程

使用密文字母表,这种表格的字母(可能)是无规律的、随机的。加解密双方都需要拿到密文字母表。

举例:
假设我们通信时只使用26个英文字母(不区分大小写)

image

(密文字母表的顺序可以随机摆放)

当你需要加密的明文:I LOVE YOU
拿着密文字母表,对照着密文字母表映射就可以了。
变成的密文:       L ORYH BRX
拿着密文字母表,对照着密文字母表解密就可以了。

无规律的解密过程——破译

原理:

  1. 考虑英文字母的使用频率规律,尝试用高频映射高频;
  2. 结合排除法。

实际案例:
TODO

有规律的加密过程

就是字母表向左或向右移动多少位,用一个图表示,方便理解:
image
用两个圆纸盘写好26个字母,相互叠起来,
外面的圆盘代表明文,
内部的圆盘代表密文,
在初始阶段,外A —— 内A,外B —— 内B,...都要一一对应上。

image
比如,向右移动3位,外A —— 内D 。

用表格表示:
image

比如说:

当你需要加密的明文:I LOVE YOU
你的加密方式,向右移动3位:
变成的密文:       F IRYH BRX

加密只需要注意两个点:

  • 偏移方向
  • 偏移量

有规律的解密过程——破译

我们知道明文就是通过偏移方向+偏移量进行加密,那么如果是破译者,怎么能知道加密时使用了什么偏移量?

比如说给你一段使用凯撒加密过的文本,你怎么解密出来?
Prqd Olvd Lq Iudqfh

破译原理:

  1. 统计出现最高频的字母;
    用凯撒密码编写的密文,字数越多就越容易破译;
  2. 要猜出字母究竟位移了多少位,要考虑英文字母的使用频率规律,尝试用高频映射高频。
    当然还可以暴力破解,毕竟只能位移1-25次,那就1-25位移全部罗列出来,哪个结果最像人写的,就是正确的。

练习:

  1. 统计
    我使用在线工具:
    https://uutool.cn/str-statistics/
    image

  2. 映射
    参考下面的字母高频使用表格:
    英文字母按使用频率从高到低排序,第一位是e、第二位是t,...
    image

    q——e ,自己用圆盘计算了一下,向右位移14次,尝试解密:Bdcp Axhp Xc Ugpcrt ,看着不对。
    ...
    d——e ,自己用圆盘算了一下,向右位移25次,尝试解密:Prqd Olvd Lq Iudqfh ,看着不对。
    d——t ,自己用圆盘算了一下,向右位移16次,尝试解密:Zban Yvfn Va Senapr ,看着不对。
    d——a ,自己用圆盘算了一下,向右位移3次,尝试解密:Mona Lisa In France ,恭喜!看着对了。
    过程可以使用在线工具:
    http://www.atoolbox.net/Tool.php?Id=778

练习

请破解:

RD IJFW OJSSD
QTANSL DTZ NX XT JFXD.
YMJWJFWJXTRFSD YMNSLX YT QTAJ FGTZY DTZ!
IT DTZ PSTB N QTAJ NY BMJS DTZ IFDIWJFR FSI DTZ YMNSP ST
TSJ NX BFYHMNSL?
IT DTZPSTB N QTAJ YMJ BFD DTZW JDJX XUFWPQJ BMJS DTZ
YJQQ F KZSSD XYTWD?
IT DTZ PSTB YMFY N QTAJ YMJ XMFUJ TK DTZW JFWX?
IT DTZ PSTB N QTAJ YT BFYHM DTZ XQJJU?
N HTZQI LT TS FSI TS.
NFR YMJWJ BNYM DTZ, QTANSL DTZ.
KWTR JIINJ

前言

最近有个网友问了我一个问题:系统中大事务问题要如何处理?

正好前段时间我在公司处理过这个问题,我们当时由于项目初期时间比较紧张,为了快速完成业务功能,忽略了系统部分性能问题。项目顺利上线后,专门抽了一个迭代的时间去解决大事务问题,目前已经优化完成,并且顺利上线。现给大家总结了一下,我们当时使用的一些解决办法,以便大家被相同问题困扰时,可以参考一下。

大事务引发的问题

在分享解决办法之前,先看看系统中如果出现大事务可能会引发哪些问题

从上图可以看出如果系统中出现大事务时,问题还不小,所以我们在实际项目开发中应该尽量避免大事务的情况。如果我们已有系统中存在大事务问题,该如何解决呢?

解决办法

少用@Transactional注解

大家在实际项目开发中,我们在业务方法加上
@Transactional
注解开启事务功能,这是非常普遍的做法,它被称为
声明式事务

部分代码如下:

   @Transactional(rollbackFor=Exception.class)
public void save(User user) {
doSameThing...
}

然而,我要说的第一条是:少用
@Transactional
注解。

为什么?

  1. 我们知道
    @Transactional
    注解是通过
    spring

    aop
    起作用的,但是如果使用不当,事务功能可能会失效。如果恰巧你经验不足,这种问题不太好排查。至于事务哪些情况下会失效,可以参考我之前写的《
    聊聊spring事务失效的12种场景,太坑了
    》这篇文章。
  2. @Transactional
    注解一般加在某个业务方法上,会导致整个业务方法都在同一个事务中,粒度太粗,不好控制事务范围,是出现大事务问题的最常见的原因。

那我们该怎么办呢?

可以使用
编程式事务
,在
spring
项目中使用
TransactionTemplate
类的对象,手动执行事务。

部分代码如下:


@Autowired
private TransactionTemplate transactionTemplate;

...

public void save(final User user) {
transactionTemplate.execute((status) => {
doSameThing...
return Boolean.TRUE;
})
}

从上面的代码中可以看出,使用
TransactionTemplate

编程式事务
功能自己灵活控制事务的范围,是避免大事务问题的首选办法。

当然,我说少使用
@Transactional
注解开启事务,并不是说一定不能用它,如果项目中有些业务逻辑比较简单,而且不经常变动,使用
@Transactional
注解开启事务开启事务也无妨,因为它更简单,开发效率更高,但是千万要小心事务失效的问题。

将查询(select)方法放到事务外

如果出现大事务,可以将查询(select)方法放到事务外,也是比较常用的做法,因为一般情况下这类方法是不需要事务的。

比如出现如下代码:

@Transactional(rollbackFor=Exception.class)
public void save(User user) {
queryData1();
queryData2();
addData1();
updateData2();
}

可以将
queryData1

queryData2
两个查询方法放在事务外执行,将真正需要事务执行的代码才放到事务中,比如:
addData1

updateData2
方法,这样就能有效的减少事务的粒度。

如果使用
TransactionTemplate

编程式事务
这里就非常好修改。


@Autowired
private TransactionTemplate transactionTemplate;

...

public void save(final User user) {
queryData1();
queryData2();
transactionTemplate.execute((status) => {
addData1();
updateData2();
return Boolean.TRUE;
})
}

但是如果你实在还是想用
@Transactional
注解,该怎么拆分呢?

public void save(User user) {
queryData1();
queryData2();
doSave();
}

@Transactional(rollbackFor=Exception.class)
public void doSave(User user) {
addData1();
updateData2();
}

这个例子是非常经典的错误,这种直接方法调用的做法事务不会生效,给正在坑中的朋友提个醒。因为
@Transactional
注解的声明式事务是通过
spring aop
起作用的,而
spring aop
需要生成代理对象,直接方法调用使用的还是原始对象,所以事务不会生效。

有没有办法解决这个问题呢?

1.新加一个Service方法

这个方法非常简单,只需要新加一个Service方法,把
@Transactional
注解加到新Service方法上,把需要事务执行的代码移到新方法中。具体代码如下:

@Servcie
publicclass ServiceA {
@Autowired
prvate ServiceB serviceB;

public void save(User user) {
queryData1();
queryData2();
serviceB.doSave(user);
}
}

@Servcie
publicclass ServiceB {

@Transactional(rollbackFor=Exception.class)
public void doSave(User user) {
addData1();
updateData2();
}

}

2.在该Service类中注入自己

如果不想再新加一个Service类,在该Service类中注入自己也是一种选择。具体代码如下:

@Servcie
publicclass ServiceA {
@Autowired
prvate ServiceA serviceA;

public void save(User user) {
queryData1();
queryData2();
serviceA.doSave(user);
}

@Transactional(rollbackFor=Exception.class)
public void doSave(User user) {
addData1();
updateData2();
}
}

可能有些人可能会有这样的疑问:这种做法会不会出现循环依赖问题?

其实
spring ioc
内部的三级缓存保证了它,不会出现循环依赖问题。如果你想进一步了解循环依赖问题,可以看看我之前文章《
spring解决循环依赖为什么要用三级缓存?
》。

3.在该Service类中使用AopContext.currentProxy()获取代理对象

上面的方法2确实可以解决问题,但是代码看起来并不直观,还可以通过在该Service类中使用AOPProxy获取代理对象,实现相同的功能。具体代码如下:

  @Servcie
public class ServiceA {

public void save(User user) {
queryData1();
queryData2();
((ServiceA)AopContext.currentProxy()).doSave(user);
}

@Transactional(rollbackFor=Exception.class)
public void doSave(User user) {
addData1();
updateData2();
}
}

事务中避免远程调用

我们在接口中调用其他系统的接口是不能避免的,由于网络不稳定,这种远程调的响应时间可能比较长,如果远程调用的代码放在某个事物中,这个事物就可能是大事务。当然,远程调用不仅仅是指调用接口,还有包括:发MQ消息,或者连接redis、mongodb保存数据等。

   @Transactional(rollbackFor=Exception.class)
public void save(User user) {
callRemoteApi();
addData1();
}

远程调用的代码可能耗时较长,切记一定要放在事务之外。


@Autowired
private TransactionTemplate transactionTemplate;

...

public void save(final User user) {
callRemoteApi();
transactionTemplate.execute((status) => {
addData1();
return Boolean.TRUE;
})
}

有些朋友可能会问,远程调用的代码不放在事务中如何保证数据一致性呢?这就需要建立:
重试
+
补偿机制
,达到数据
最终一致性
了。

事务中避免一次性处理太多数据

如果一个事务中需要处理的数据太多,也会造成大事务问题。比如为了操作方便,你可能会一次批量更新1000条数据,这样会导致大量数据锁等待,特别在高并发的系统中问题尤为明显。

解决办法是分页处理,1000条数据,分50页,一次只处理20条数据,这样可以大大减少大事务的出现。

非事务执行

在使用事务之前,我们都应该思考一下,是不是所有的数据库操作都需要在事务中执行?


@Autowired
private TransactionTemplate transactionTemplate;

...

public void save(final User user) {
transactionTemplate.execute((status) => {
addData();
addLog();
updateCount();
return Boolean.TRUE;
})
}

上面的例子中,其实
addLog
增加操作日志方法 和
updateCount
更新统计数量方法,是可以不在事务中执行的,因为操作日志和统计数量这种业务允许少量数据不一致的情况。


@Autowired
private TransactionTemplate transactionTemplate;

...

public void save(final User user) {
transactionTemplate.execute((status) => {
addData();
return Boolean.TRUE;
})
addLog();
updateCount();
}

当然大事务中要鉴别出哪些方法可以非事务执行,其实没那么容易,需要对整个业务梳理一遍,才能找出最合理的答案。

异步处理

还有一点也非常重要,是不是事务中的所有方法都需要同步执行?我们都知道,方法同步执行需要等待方法返回,如果一个事务中同步执行的方法太多了,势必会造成等待时间过长,出现大事务问题。

看看下面这个列子:


@Autowired
private TransactionTemplate transactionTemplate;

...

public void save(final User user) {
transactionTemplate.execute((status) => {
order();
delivery();
return Boolean.TRUE;
})
}

order
方法用于下单,
delivery
方法用于发货,是不是下单后就一定要马上发货呢?

答案是否定的。

这里发货功能其实可以走mq异步处理逻辑。


@Autowired
private TransactionTemplate transactionTemplate;

...

public void save(final User user) {
transactionTemplate.execute((status) => {
order();
return Boolean.TRUE;
})
sendMq();
}

总结

本人从网友的一个问题出发,结合自己实际的工作经验分享了处理大事务的6种办法:

  1. 少用@Transactional注解
  2. 将查询(select)方法放到事务外
  3. 事务中避免远程调用
  4. 事务中避免一次性处理太多数据
  5. 非事务执行
  6. 异步处理

最后说一句(求关注,别白嫖我)

如果这篇文章对您有所帮助,或者有所启发的话,帮忙扫描下发二维码关注一下,您的支持是我坚持写作最大的动力。

求一键三连:点赞、转发、在看。
关注公众号:【苏三说技术】,在公众号中回复:面试、代码神器、开发手册、时间管理有超赞的粉丝福利,另外回复:加群,可以跟很多BAT大厂的前辈交流和学习。

最近公司有个需求是,拖动文件到桌面图标上,自动打开文件。那么只需在OnStartup事件中通过StartupEventArgs获取文件名然后进行操作即可。操作之后发现当软件已经启动了(单例运行),那么将无法将参数传给业务层。原因是因为跨进程了,那么我们可以通过窗口句柄的方式来进行通讯。

1  public partial classApp : Application2 {3      private staticMutex AppMutex;4      publicApp()5 {6 
7 }8 
9      protected override voidOnStartup(StartupEventArgs e)10 {11          //获取启动参数
12          var param = string.Empty;13          if (e.Args.Length > 0)14 {15              param = e.Args[0].ToString();16 }17 
18          //WpfApp8 = 你的项目名称
19          AppMutex = new Mutex(true, "WpfApp8", out varcreatedNew);20 
21          if (!createdNew)22 {23              var current =Process.GetCurrentProcess();24 
25              foreach (var process inProcess.GetProcessesByName(current.ProcessName))26 {27                  if (process.Id !=current.Id)28 {29 Win32Helper.SetForegroundWindow(process.MainWindowHandle);30 Win32Helper.SendMessageString(process.MainWindowHandle, param);31                      break;32 }33 }34              Environment.Exit(0);35 }36          else
37 {38              base.OnStartup(e);39 }40 }41  }
1  public classWin32Helper2 {3      [DllImport("user32.dll", ExactSpelling = true, CharSet =CharSet.Auto)]4      public static extern boolSetForegroundWindow(IntPtr hWnd);5 
6      /// <summary>
7      ///发送消息8      /// </summary>
9      /// <param name="hWnd"></param>
10      /// <param name="Msg"></param>
11      /// <param name="wParam"></param>
12      /// <param name="lParam"></param>
13      /// <returns></returns>
14      [DllImport("user32.dll")]15      public static extern IntPtr SendMessage(IntPtr hWnd, int Msg, IntPtr wParam, refCOPYDATASTRUCT lParam);16 
17      //声明常量
18      public const int WM_COPYDATA = 0x004A;19 
20      //定义 COPYDATASTRUCT 结构
21 [StructLayout(LayoutKind.Sequential)]22      public structCOPYDATASTRUCT23 {24          publicIntPtr dwData;25          public intcbData;26          publicIntPtr lpData;27 }28 
29      /// <summary>
30      ///发送字符串消息31      /// </summary>
32      /// <param name="hWnd"></param>
33      /// <param name="message"></param>
34      public static void SendMessageString(IntPtr hWnd, stringmessage)35 {36          if (string.IsNullOrEmpty(message)) return;37 
38          byte[] messageBytes = Encoding.Unicode.GetBytes(message + '\0'); //添加终止符
39 
40          COPYDATASTRUCT cds = newCOPYDATASTRUCT();41          cds.dwData =IntPtr.Zero;42          cds.cbData =messageBytes.Length;43          cds.lpData =Marshal.AllocHGlobal(cds.cbData);44          Marshal.Copy(messageBytes, 0, cds.lpData, cds.cbData);45          try
46 {47              SendMessage(hWnd, WM_COPYDATA, IntPtr.Zero, refcds);48 }49          finally
50 {51              //释放分配的内存,即使发生异常也不会泄漏资源
52 Marshal.FreeHGlobal(cds.lpData);53 }54 }55  }
1  public partial classMainWindow : Window2 {3      publicMainWindow()4 {5 InitializeComponent();6 }7 
8      protected override voidOnSourceInitialized(EventArgs e)9 {10          base.OnSourceInitialized(e);11 
12          HwndSource hwndSource = PresentationSource.FromVisual(this) asHwndSource;13 hwndSource.AddHook(WndProc);14 }15 
16      private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref boolhandled)17 {18          if (msg ==WM_COPYDATA)19 {20              COPYDATASTRUCT cds = (COPYDATASTRUCT)Marshal.PtrToStructure(lParam, typeof(COPYDATASTRUCT));21              string receivedMessage =Marshal.PtrToStringUni(cds.lpData);22 
23              Console.WriteLine("收到消息:" +receivedMessage);24 
25              //TODO:业务处理
26 MessageBox.Show(receivedMessage);27 
28              handled = true;29 }30 
31          returnIntPtr.Zero;32 }33  }

在之前的文章中,我们使用WebGL绘制了很多二维的图形和图像,在学习2D绘图的时候,我们提过很多次关于GPU的高效渲染,但是2D图形的绘制只展示了WebGL部分的能力,WebGL更强大的地方在于,它可以绘制各种3D图形,而3D图形能够极大地增强可视化的表现能力。

相信很多小伙伴都对此有所耳闻,也有不少人学习WebGL,就是冲着它的3D绘图能力。

接下来,我就用一个简单的正立方体的例子来演示在WebGL中如何绘制3D物体。

从二维到三维

首先,我们先来绘制一个熟悉的2D图形,正方形。

// vertex
attribute vec2 a_vertexPosition;
attribute vec4 color;

varying vec4 vColor;

void main() {
  gl_PointSize = 1.0;
  vColor = color;
  gl_Position = vec4(a_vertexPosition, 1, 1);
}

// fragment
#ifdef GL_ES
precision highp float;
#endif

varying vec4 vColor;

void main() {
  gl_FragColor = vColor;
}
// ...
renderer.setMeshData([{
  positions: [
    [-0.5, -0.5],
    [-0.5, 0.5],
    [0.5, 0.5],
    [0.5, -0.5]
  ],
  attributes: {
    color: [
      [1, 0, 0, 1],
      [1, 0, 0, 1],
      [1, 0, 0, 1],
      [1, 0, 0, 1],
    ]
  },
  cells: [[0, 1, 2], [2, 0, 3]]
}]);
// ...

上述这些代码比较简单,我就不过多解释了。

在画布上我们看到,绘制了一个红色的正方形,它是一个平面图形。

接下来,我们就在这个图形的基础上,将它拓展为3D的正立方体。

要想把2维图形拓展为3维几何体,第一步就是要把顶点扩展到3维。
也就是把vec2扩展为vec3。

// vertex
attribute vec3 a_vertexPosition;
attribute vec4 color;

varying vec4 vColor;

void main() {
  gl_PointSize = 1.0;
  vColor = color;
  gl_Position = vec4(a_vertexPosition, 1);
}

当然仅仅修改Shader是不够的,因为数据是从JavaScript传递过来的,所以我们需要在JavaScript中计算立方体的顶点数据,然后再传递给Shader。

一个立方体有8个顶点,能组成6个面。在WebGL中需要用12个三角形来绘制它。

如果6个面的属性相同的话,我们可以复用8个顶点来绘制;

但如果属性不完全相同,比如每个面要绘制成不同的颜色,或者添加不同的纹理图片,就得把每个面的顶点分开。这样的话,就需要24个顶点来分别处理6个面。

为了方便使用,我们可以定义一个JavaScript函数,用来生成立方体6个面的24个顶点,以及12个三角形的索引,并且定义每个面的颜色。

/**
 * 生成立方体6个面的24个顶点,12个三角形的索引,定义每个面的颜色信息
 * @param size
 * @param colors
 * @returns {{cells: *[], color: *[], positions: *[]}}
 */
export function cube(size = 1.0, colors = [[1, 0, 0, 1]]) {
    const h = 0.5 * size;
    const vertices = [
        [-h, -h, -h],
        [-h, h, -h],
        [h, h, -h],
        [h, -h, -h],
        [-h, -h, h],
        [-h, h, h],
        [h, h, h],
        [h, -h, h]
    ];

    const positions = [];
    const color = [];
    const cells = [];

    let colorIdx = 0;
    let cellsIdx = 0;
    const colorLen = colors.length;

    function quad(a, b, c, d) {
        [a, b, c, d].forEach(item => {
            positions.push(vertices[item]);
            color.push(colors[colorIdx % colorLen]);
        });
        cells.push(
            [0, 1, 2].map(i => i + cellsIdx),
            [0, 2, 3].map(i => i + cellsIdx)
        );
        colorIdx ++;
        cellsIdx += 4;
    }

    quad(1, 0, 3, 2); // 内
    quad(4, 5, 6, 7); // 外
    quad(2, 3, 7, 6); // 右
    quad(5, 4, 0, 1); // 左
    quad(3, 0, 4, 7); // 下
    quad(6, 5, 1, 2); // 上

    return {positions, color, cells};
}

现在我们就可以通过调用cube这个函数,构建出立方体的顶点信息。

const geometry = cube(1.0, [
    [1, 0, 0, 1],   // 红
    [0, 0.5, 0, 1], // 绿
    [0, 0, 1, 1]    // 蓝
]);

通过这段代码,我们就能创建出一个棱长为1的立方体,并且六个面的颜色分别是“红、绿、蓝、红、绿、蓝”。

接下来我们就要把这个立方体的顶点信息传递给Shader。

在传递数据之前,我们需要先了解一个知识点,是关于绘制3D图形与2D图形存在的一点不同,那就是绘制3D图形时,必须要开启深度检测和启用深度缓冲区。

在WebGL中,我们可以通过
gl.enable(gl.DEPTH_TEST);
这段代码来开启深度检测;在清空画布的时候,也要用
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
这段代码来同时清空颜色缓冲区和深度缓冲区。

启动和清空深度检测和深度缓冲区这两个步骤,非常重要。但是一般情况下,我们几乎不会用原生的方式来编写代码,所以了解一下即可。为了方便使用,在本文演示的例子中,我们还是直接使用gl-renderer这个库,它封装了深度检测,我们在使用时,在创建renderer的时候配置一个参数
depth: true
就可以了。

现在我们就把这个三维立方体用gl-renderer渲染出来。

// ...
renderer = new GlRenderer(glRef.value, {
  depth: true // 开启深度检测
});
const program = renderer.compileSync(fragment, vertex);
renderer.useProgram(program);
renderer.setMeshData([{
  positions: geometry.positions,
  attributes: {
    color: geometry.color
  },
  cells: geometry.cells
}]);
renderer.render();

现在我们在画布上看到的是一个红色正方形,这是因为其他面被遮挡住了。

投影矩阵:变换WebGL坐标系

但是,等等,为什么我们看到的是红色的一面呢?按照我们所编写的代码,预期看到的应该是绿色的一面,也就是说我们预期Z轴是向外的,因为规范的直角坐标系是右手坐标系。所以按照现在的绘制结果,我们发现WebGL的坐标系其实是左手系的?

但一般来说,不管什么图形库或者图形框架,在绘图的时候,都会默认将坐标系从左手系转换为右手系,因为这更符合我们的使用习惯。所以这里,我们也去把WebGL的坐标系从左手系转换为右手系,简单来说,就是将Z轴坐标方向反转。关于坐标转换,可以通过齐次矩阵来完成。对坐标转换不熟悉的小伙伴,可以参考我之前的一篇关于仿射变换的文章。

将Z轴坐标方向反转,对应的齐次矩阵是这样的:

[
	1, 0, 0, 0,
	0, 1, 0, 0,
	0, 0, -1, 0,
	0, 0, 0, 1
]

这种转换坐标的齐次矩阵,也被称为
投影矩阵
,ProjectionMatrix。

现在我们修改一下顶点着色器,把这个投影矩阵添加进去。

// vertex
attribute vec3 a_vertexPosition; // 1:把顶点从vec2扩展到vec3
attribute vec4 color; // 四维向量

varying vec4 vColor;
uniform mat4 projectionMatrix; // 2:投影矩阵-变换坐标系

void main() {
  gl_PointSize = 1.0;
  vColor = color;
  gl_Position = projectionMatrix * vec4(a_vertexPosition, 1.0);
}

现在我们就能看到画布上显示的是绿色的正方形了。

模型矩阵:让立方体旋转起来

现在我们只能看到立方体的一个面,因为Z轴是垂直于屏幕的,这样子从视觉上看好像和2维图形没什么区别,没法让人很直观地联想、感受到这是一个三维的几何体,为了将其他的面露出来,我们可以去旋转立方体。

要想旋转立方体,我们同样可以通过矩阵运算来实现。这个矩阵叫做
模型矩阵
,ModelMatrix,它定义了被绘制的物体变换。

把模型矩阵加入到顶点着色器中,将它与投影矩阵相乘,再乘上齐次坐标,就得到最终的顶点坐标了。

attribute vec3 a_vertexPosition; // 1:把顶点从vec2扩展到vec3
attribute vec4 color; // 四维向量

varying vec4 vColor;
uniform mat4 projectionMatrix; // 2:投影矩阵-变换坐标系
uniform mat4 modelMatrix; // 3:模型矩阵-使几何体旋转

void main() {
  gl_PointSize = 1.0;
  vColor = color;
  gl_Position = projectionMatrix * modelMatrix * vec4(a_vertexPosition, 1.0);
}

现在我们定义一个JavaScript函数,用立方体沿x、y、z轴的旋转来生成模型矩阵。

以x、y、z三个方向的旋转得到三个齐次矩阵,然后将它们相乘,就能得到最终的模型矩阵。

import { multiply } from 'ogl/src/math/functions/Mat4Func.js';
// ...
export function fromRotation(rotationX, rotationY, rotationZ) {
    let c = Math.cos(rotationX);
    let s = Math.sin(rotationX);
    const rx = [
        1,  0, 0, 0, // 绕X轴旋转
        0,  c, s, 0,
        0, -s, c, 0,
        0,  0, 0, 1
    ];

    c = Math.cos(rotationY);
    s = Math.sin(rotationY);
    const ry = [
        c,  0, s, 0,
        0,  1, 0, 0, // 绕Y轴旋转
        -s, 0, c, 0,
        0,  0, 0, 1
    ];

    c = Math.cos(rotationZ);
    s = Math.sin(rotationZ);
    const rz = [
        c,  s, 0, 0,
        -s, c, 0, 0,
        0,  0, 1, 0, // 绕Z轴旋转
        0,  0, 0, 1
    ];

    const ret = [];
    multiply(ret, rx, ry);
    multiply(ret, ret, rz);
    return ret;
}

我们把模型矩阵传给顶点着色器,不断更新三个旋转角度,就能实现立方体旋转的效果。

// ...
let rotationX = 0;
let rotationY = 0;
let rotationZ = 0;
function update() {
  rotationX += 0.003;
  rotationY += 0.005;
  rotationZ += 0.007;
  renderer.uniforms.modelMatrix = fromRotation(rotationX, rotationY, rotationZ);
  requestAnimationFrame(update);
}
update();
// ...

现在我们就能在旋转中看到立方体的其他几个面了,能更直观地感受到这是一个三维物体。

总结

至此,我们就实现了正立方体的绘制。在3D物体的绘制中,正立方体属于是比较简单的一类,屏幕前的小伙伴们都可以来动手尝试下,感兴趣的小伙伴,还可以尝试去实现圆柱体、正四面体等等这些几何体的绘制。

参考代码

效果预览

CvT将Transformer与CNN在图像识别任务中的优势相结合,从CNN中借鉴了多阶段的层级结构设计,同时引入了Convolutional Token Embedding和Convolutional Projection操作增强局部建模能力,在保持计算效率的同时实现了卓越的性能。此外,由于卷积的引入增强了局部上下文建模能力,CvT不再需要position Embedding,这使其在适应各种需要可变输入分辨率的视觉任务方面更具有优势

来源:晓飞的算法工程笔记 公众号

论文: CvT: Introducing Convolutions to Vision Transformers

Introduction


作者提出了一种名为Convolutional vision Transformer(CvT) 的新架构,通过将引入卷积网络的设计来提高ViT的性能和效率。CvT从CNN中借鉴了多阶段的层级结构设计,同时引入了Convolutional Token Embedding和Convolutional Projection两个新模块,分别用于增加block输入和中间特征的局部建模能力,提高效率。

CvT能够将CNN的理想特性(位移、缩放和失真的不变性)引入了ViT,同时保持Transformer的优点(动态注意力、全局上下文和更好的泛化能力)。由于卷积的引入,CvT可以移除Position Embedding,这使其在适应各种需要可变输入分辨率的视觉任务方面更具有优势。

在ImageNet-1k上,CvT到达优于其他Vision Transformer和ResNet的性能,并且参数更少且FLOP更低。当在ImageNet-22k上预训练后,CvT-W24在ImageNet-1k验证集上获得了 87.7%的top-1准确率。

Convolutional vision Transformer


CvT的整体结构如图2所示,在ViT架构中引入了两种基于卷积的操作:Convolutional Token Embedding和Convolutional Projection,同时也从CNN中借鉴了多阶段的层级结构设计。

如图2a所示,CvT包含三个阶段,每个阶段有两个部分:

  • 使用Convolutional Token Embedding层将输入图像(或2D重构的token图)进行处理,该层由卷积实现,外加层归一化。这使得每个阶段能够逐渐减少token的数量同时增加token的维度,从而实现空间下采样和增加特征的丰富性,类似于CNN的设计。与其他基于Transformer的架构不同,CvT不会将position embedding与token相加,这得益于卷积操作本身就建模了位置信息。
  • 堆叠的Convolutional Transformer Block组成了每个阶段的其余部分。 Convolutional Transformer Block的结构如图2b所示,其中的Convolutional Projection为深度可分离卷积,用于
    Q

    K

    V
    embedding的转换,代替常见的矩阵线性投影。此外,class token仅在最后阶段添加,使用MLP对最后阶段输出的分类token进行类别预测。

Convolutional Token Embedding

CvT中的卷积操作主要是为了参考CNN的多阶段层级方法来对局部空间的上下文进行建模,从低级边缘特征到高阶语义特征。

给定一个2D图像或来自前一个阶段的2D重构输出
\(x_{i−1}\in \mathbb{R}^{H_{i−1}\times W_{i−1}\times C_{i−1}}\)
作为阶段
i
的输入,训练卷积函数
\(f(\cdot)\)

\(x_{i−1}\)
转换成维度为
\(C_i\)
的新token$ f(x_{i−1})
\(。其中\)
f(\cdot)
\(的内核大小为\)
s\times s
\(、步幅为\)
s - o
\(和填充大小为\)
p
\(。新的token图\)
f(x_{i−1})\in \mathbb{R}^{H_{i}\times W_{i}\times C_{i}}$的高度和宽度为:

\(f(x_{i−1})\)
随后展开为
\(H_i W_i\times C_i\)
的序列,并且在输入到后续层前通过通过层进行归一化。

Convolutional Token Embedding层可以通过改变卷积的参数来调整每个阶段的token特征维度和token数量,每个阶段逐渐减少token序列长度,同时增加token特征维度。这使得token能够在更大的空间上表达越来越复杂的视觉模式,类似于CNN的特征层。

Convolutional Projection for Attention

Convolutional Projection层的目标是实现局部空间上下文的建模,并通过对
Q

K

V
矩阵进行欠采样来提供效率优势。

虽然之前的研究也有尝试在Transformer Block中添加额外的卷积模块来进行语音识别和自然语言处理,但这些研究都带来更复杂的设计和额外的计算成本。相反,作者建议用深度可分离卷积替换多头自注意力的原始位置线性投影,得到Convolutional Projection层。

  • Implementation Details

图3a展示了ViT中使用的原始位置线性投影,图3b展示了作者提出的
\(s\times s\)
Convolutional Projection操作。如图3b所示,token序列先重塑为2D token图,接着使用内核大小为
s
的深度可分离卷积层实现转换。最后,将得到的token图展开为一维以进行后续处理。这可以表述为:

其中
\(x^{q/k/v}\)
是第
i

Q/K/V
输入矩阵,
\(x_i\)
是转换之前的token序列,
Conv2d
是深度方向可分离卷积,由以下方式实现:
Depth-wise Conv2d → BatchNorm2d → Point-wise Conv2d

s
指卷积核大小。

带有Convolutional Projection层的新Transformer block实际可认为是原始Transformer block的统一范式,将内核大小设置为
\(1×1\)
即是原始的位置线性投影层。

  • Efficiency Considerations

Convolutional Projection层的设计有两个主要的效率优势:

  • 首先,使用更高效的卷积。使用标准
    \(s\times s\)
    卷积需要
    \(s^2 C^2\)
    的参数和
    \(\mathcal{O}(s^2 C^2T)\)
    的FLOP。将标准卷积拆分为深度可分离卷积则只会引入额外的
    \(s^2 C\)
    的参数和$\mathcal{O}(s^2CT ) $的FLOP,这对于模型的总参数和FLOP而言可以忽略不计。
  • 其次,使用Convolutional Projection来降低MHSA操作的计算成本。如图3c所示,
    K

    V
    通过步幅大于
    1
    的卷积进行子采样,
    Q
    转换则使用步幅为
    1
    不变。这样
    K

    V
    的token数量减少了4倍,后期MHSA操作的计算量减少了4倍。这仅带来了些许的性能损失,因为图像中的相邻像素往往在外观或语义上有冗余。此外,Convolutional Projection的局部上下文建模补偿了分辨率降低带来的信息损失。

4. Experiments


  • Model Variants

作者通过改变每个阶段的Transformer Block数量和中间特征维度,设计了三个具有不同参数和FLOP的模型,如表2所示。

Comparison to state of the art

与SOTA方法对比。

Downstream task transfer

下游任务的迁移能力对比。

Ablation Study

  • Removing Position Embedding

对比position embedding对CvT的影响。

  • Convolutional Token Embedding

对比Convolutional Token Embedding模块的有效性。

  • Convolutional Projection

对比Convolutional Projection中的下采样做法的影响。

对比Convolutional Projection的有效性。

Conclusion


CvT将Transformer与CNN在图像识别任务中的优势相结合,从CNN中借鉴了多阶段的层级结构设计,同时引入了Convolutional Token Embedding和Convolutional Projection操作增强局部建模能力,在保持计算效率的同时实现了卓越的性能。此外,由于卷积的引入增强了局部上下文建模能力,CvT不再需要position Embedding,这使其在适应各种需要可变输入分辨率的视觉任务方面更具有优势。



如果本文对你有帮助,麻烦点个赞或在看呗~
更多内容请关注 微信公众号【晓飞的算法工程笔记】

work-life balance.