使用 canvas 来实现简单的批注功能

前言

使用 canvas 来实现简单的批注功能。

最近,公司需要做(抄)一个和剪映相似的页面,点我直达

在剪映中,有一个批注的功能,能够对视频画面进行标注,效果如下:

在经过我对 dom 的分析之后,我发现这个功能是经过 canvas 实现。

具体就是在视频容器的区域内套一个 canvas 元素,然后在上面绘制。

在这篇文章中,我们主要是分析如何画出指向和方框这两种批注。

正文

dom 结构

首先,我们先创建一个 canvas 节点,这里我们用 vue 项目来实现。

1
2
3
4
5
6
7
8
9
<script setup lang="ts">
import { ref } from "vue";

const canvasRef = ref<HTMLCanvasElement | null>(null);
</script>

<template>
<canvas ref="canvasRef" width="1280" height="720"></canvas>
</template>

这里我们固定了宽高,当然实际上在 resize 事件(或者 ResizeObserver 观察器)时,我们可能需要重新设置 canvas 的宽高,这里我们简单处理。

接着我们要处理事件,这里我们主要需要三个事件,mousedown , mousemovemouseup ,这里我们用了 vueuse/core ,主要是使用 useEventListener 这个组合式 api 。

这样我们可以专注于逻辑,而不用去在意事件的绑定与解绑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<script setup lang="ts">
import { ref } from "vue";
import { useEventListener } from "@vueuse/core";

const canvasRef = ref<HTMLCanvasElement | null>(null);
let mousedown = false;

useEventListener(canvasRef, "mousedown", () => {
mousedown = true;
});
useEventListener("mousemove", () => {
if (!mousedown) {
return;
}
// TODO
});
useEventListener("mouseup", () => {
mousedown = false;
});
</script>

接下来我们要考虑下存储的数据结构,在剪映中,数据结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
interface AnnotationItem {
type: "rect" | "arrow";
from: {
x: number;
y: number;
};
to: {
x: number;
y: number;
};
// 一些其他的字段
}

当然,这里我们只保留一些核心的字段,在剪映中还有一些额外的字段,比如批注的颜色 color 字段,这里我们从简,统一使用 #ff0000 红色。

然后我们需要有两个变量来保存批注对象,其中一个为 annotationList ,保存已经不再变化的批注对象,另一个为 currentAnnotationItem , 保存当前正在创建的批注对象

这里我们需要一些坐标相关的计算,需要使用 vueuse/coreuseElementBounding 来获取 canvas 的盒子信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
<script setup lang="ts">
import { ref } from "vue";
import { useEventListener } from "@vueuse/core";

interface AnnotationItem {
type: "rect" | "arrow";
from: {
x: number;
y: number;
};
to: {
x: number;
y: number;
};
// 一些其他的字段
}

const canvasRef = ref<HTMLCanvasElement | null>(null);
let mousedown = false;
const annotationList = ref<AnnotationItem[]>([]);
const currentAnnotationItem = ref<AnnotationItem | null>(null);

const {
left: canvasLeft,
top: canvasTop,
width: canvasWidth,
height: canvasHeight,
} = useElementBounding(canvasRef);

useEventListener(canvasRef, "mousedown", ({ clientX, clientY }: MouseEvent) => {
mousedown = true;
// 记录起始点信息
currentAnnotationItem.value = {
type: "rect",
from: {
x: clientX - canvasLeft.value,
y: clientY - canvasTop.value,
},
to: {
x: 0,
y: 0,
},
};
});
useEventListener("mousemove", ({ clientX, clientY }: MouseEvent) => {
if (!mousedown) {
return;
}
// 实时更新当前结束点坐标
Object.assign(currentAnnotationItem.value!.to, {
x: clientX - canvasLeft.value,
y: clientY - canvasTop.value,
});
});
useEventListener("mouseup", ({ clientX, clientY }: MouseEvent) => {
if (mousedown) {
mousedown = false;
// 记录结束点信息
Object.assign(currentAnnotationItem.value!.to, {
x: clientX - canvasLeft.value,
y: clientY - canvasTop.value,
});
// 存到数组中
annotationList.value.push(Object.assign({}, currentAnnotationItem.value));
currentAnnotationItem.value = null;
}
});
</script>

现在我们已经得到了一个批注对象,接下来我们就需要将这个对象画到画布上面。

上面的代码中,在 mousedown 中使用了 type = "rect" 来初始化批注对象。

所以我们先来讲讲怎么画矩形标注。

矩形标注

在 Canvas 的 2D 上下文中,已经有一个现成的绘制矩形的 API 了,即 strokeRect(x, y, w, h)

这四个参数分别是,起始点的横坐标,起始点的纵坐标,矩形的宽,矩形的高。

这里我们可以看到剪映用的也是这个 API 。

现在我们的代码中保存了 currentAnnotationItem 这个对象,这个对象里面有起始点坐标和结束点坐标。

所以 API 需要的四个参数我们都能通过计算得到,我们写一个 drawRect 函数来绘制 currentAnnotationItem 所表示的矩形。

1
2
3
4
5
6
7
8
9
10
11
const drawRect = () => {
const ctx = canvasRef.value!.getContext("2d")!;
const { from, to } = currentAnnotationItem.value!;
const width = Math.abs(from.x - to.x);
const height = Math.abs(from.y - to.y);
ctx.beginPath();
ctx.strokeStyle = "#ff0000";
ctx.lineWidth = 2;
ctx.strokeRect(from.x, from.y, width, height);
ctx.closePath();
};

然后我们在 mousemove 事件中加上对这个函数的调用

1
2
3
4
5
6
7
8
9
10
11
12
useEventListener("mousemove", ({ clientX, clientY }: MouseEvent) => {
if (!mousedown) {
return;
}
// 实时更新当前结束点坐标
Object.assign(currentAnnotationItem.value!.to, {
x: clientX - canvasLeft.value,
y: clientY - canvasTop.value,
});
// 绘制
drawRect();
});

效果如下:

可以发现现在的绘制存在两个问题:

  • 问题 1 :每次绘制都会保留上次绘制的结果,导致显示错误。
  • 问题 2 :由于我们固定起始点 fromstrokeRect 的头两个参数,导致当结束点 tofrom 的左上角时会出现绘制错误。

针对问题 1 ,我们需要在每次 mousemove 回调中的绘制前清除画布,我们实现一个 clearCanvas 函数,来清除画布上的当前内容。

清除画布的方法可以是调用 2D 上下文的 clearRect ,或者重新给 canvas 元素的宽高赋值,这里由于我们需要清空整个画布,所以用哪个都没差,这里我们使用后面的方法。

1
2
3
4
5
const clearCanvas = () => {
const el = canvasRef.value!;
el.width = el.width;
el.height = el.height;
};

然后我们在 mousemove 中的 drawRect 之前调用一次 clearCanvas

1
2
3
4
5
6
7
8
9
10
11
12
13
14
useEventListener("mousemove", ({ clientX, clientY }: MouseEvent) => {
if (!mousedown) {
return;
}
// 实时更新当前结束点坐标
Object.assign(currentAnnotationItem.value!.to, {
x: clientX - canvasLeft.value,
y: clientY - canvasTop.value,
});
// 清空画布
clearCanvas();
// 绘制
drawRect();
});

效果如下:

可以看到现在不会出现重叠的情况了。

接下来我们开始解决问题 2 ,从上面的图可能看不出来问题 2 的症状,下面这个图就比较清晰了。

在我们从右下往左上拖动的时候,矩形绘制的区域明显错误了。

这个问题的根本原因在于我们固定了 currentAnnotationItem.from 作为矩形的起始点。

绘制的时候我们总共会出现四个绘制方向,分别是:

  • 最常见的就是左上右下
  • 左下右上
  • 右上左下
  • 右下左上

接着我们一个个分析。

1. 左上右下

很明显此时绘制矩形的顶点就是 from 的坐标,这个很容易看出来。

2. 左下右上

此时矩形的顶点是 (from.x, to.y)

3.右上左下

此时矩形的顶点是 (to.x, from.y)

4.右下左上

此时矩形的顶点是 to 的坐标。

经过分析之后,我们可以发现我们应该分别取 fromto 两者横纵坐标的较小值,这样绘制出来的矩形才是正确的

所以我们改动下 drawRect 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const drawRect = () => {
const ctx = canvasRef.value!.getContext("2d")!;
const { from, to } = currentAnnotationItem.value!;
const width = Math.abs(from.x - to.x);
const height = Math.abs(from.y - to.y);
ctx.beginPath();
ctx.strokeStyle = "#ff0000";
ctx.lineWidth = 2;
ctx.strokeRect(
// 改动部分开始
Math.min(from.x, to.x),
Math.min(from.y, to.y),
// 改动部分结束
width,
height
);
ctx.closePath();
};

经过修改之后,效果如下:

现在矩形绘制基本上正确了

箭头标注

这应该是本文最难的一个点了,在刚开始我也是不会的,不过我也是看了剪映里面的绘制代码,调试了很久才勉强懂得了过程。

这里我们先放一下剪映的代码。

看起来还是相当复杂的,涉及了三角函数。

当然,这其中的基础是 2d 上下文的 moveTo , lineTo , fill API,分别是:

  • moveTo(x, y) 移动画笔到点 (x, y)
  • lineTo(x, y) 从起始点到 (x, y) 连接一条路径。
  • fill() 填充绘制路径围成的区域。

在剪映的代码中,最后就是调用这 3 个 API 来绘制图形

1
2
3
4
5
6
7
8
9
10
11
12
13
t.prototype.draw = function() {
// ...
e.beginPath();
e.fillStyle = c;
e.moveTo(l.x, l.y);
e.lineTo(_.x, _.y);
e.lineTo(b.x, b.y);
e.lineTo(f.x, f.y);
e.lineTo(w.x, w.y);
e.lineTo(E.X, E.y);
e.fill();
e.closePath();
}

这里的每个点就是箭头标注的点,总共 6 个点,即 7 条边。

那么我们现在就是要求出这 6 个点的坐标,其他就水到渠成了。

其中两个点其实我们已经得到了,分别是 fromto ,对应的代码为 e.moveTo(l.x, l.y)e.lineTo(f.x, f.y)

我们可以把剪映的代码稍稍转换一下,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
t.prototype.draw = function() {
// ...
d = Math.sqrt(Math.pow(to.x - from.x, 2) + Math.pow(to.y - from.y, 2));
h = Math.min(.2 * d, 35);
p = .7 * h;
y = Math.atan(Math.abs(to.y - from.y) / Math.abs(to.x - from.x));
v = Math.PI / 4;
m = to.x > from.x ? 1 : -1;
g = to.y > from.y ? 1 : -1;
b = {
x: to.x - h * Math.cos(v - y) * m,
y: to.y + h * Math.sin(v - y) * g
};
w = {
x: to.x - h * Math.cos(v + y) * m,
y: to.y - h * Math.sin(v + y) * g
};
_ = {
x: to.x - p * Math.cos(v - y - v / 2) * m,
y: to.y + p * Math.sin(v - y - v / 2) * g
};
E = {
x: to.x - p * Math.cos(v + y - v / 2) * m,
y: to.y - p * Math.sin(v + y - v / 2) * g
};
// ...
}

首先变量 d 很容易看出来是计算点 from 和点 to 围成矩形的对角线的长度,这个公式是我们很熟悉的勾股定理 z2 = x2 + y2

变量 hp 分别是根据变量 d 计算的一个的长度,往下看,可以发现分别对应去计算了两个点的坐标,其中变量 h 对应变量 b 和变量 w ,变量 p 对应变量 _ 和变量 E

y 则是矩形的对角线和横向形成的对角线,即下图的角 θ

Math.atan() 就是 Math.tan() 的“相反面”,它有一个专业的术语,叫反正切。

对于 Math.tan() ,传入角度,得到对边与领边的比值,而 Math.atan() 则是传入对边与领边的比值,得到角度。

v 则是 Math.PI 的 1/4 ,Math.PI 表示 180° , 1/4 也就是 45° 。

mg 则是用来补偿正负判断的。

接下来我们假设从左下移动到右上,且夹角为 30° ,此时正负补偿都是 1 ,我们可以忽略这两个变量。

我们以下半部分的箭头来分析,此时如下:

接下来我们分析下下图中标的点,这里我们标为点 t

结合代码可以发现,由于四个待求的点 b, w, _, E 都是通过目标点 to 来进行转化的,而此时点 t 的横纵坐标应该都要小于 to ,即 to 的横纵坐标都要减去某个值来得到点 t

那么此时可以排除 b_ ,剩下 wE

在箭头的一边包含两个点,除了点 t ,还有一个 点 l ,如下图:

那么 wE 应该就对应了这两个点。

此时我们假设 w 对应点 t ,然后我们分析是否符合。

此时 w 的计算如下(正负补偿已忽略):

1
2
3
4
w = {
x: to.x - h * Math.cos(v + y),
y: to.y - h * Math.sin(v + y)
};

可以发现通过 h 来以及正余弦来得出偏移量,那么我们可以确此时 h 就是斜边,而 h * Math.cos(v + y)h * Math.sin(v + y) 就是直角边。

那么我们可以得出此时构造的直角三角形应该如下图所示:

此时角 α + 角 β 的值即为 y + v(45°) ,所以 h * Math.cos(v + y) 得出了偏移量 pxh * Math.sin(v + y) 得出了偏移量 py

那么 t 的坐标也就的出来了。

同理我们分析 l 坐标,我们可以同样构造三角形,但是我们发现角度额外减少了 22.5°, 即 v(45°)/ 2

以及使用 p( =0.7 * h ) 来作为斜边,而不是 h ,此时我们把点 l 和 点 to 连接起来,此时两点的长度就是 p

我们直接上图,更容易理解:

此时 角 α + 角 β 的值即为 y + v / 2(22.5°)

其他的就和点 t 的计算过程一样了。

当然这里需要注意的是,v 的角度其实是可以自定义的,即如果你增大了 v 值,那么箭头顶部就会更加往外扩大,而如果你减小 v 值,那么箭头顶部就会往内收敛。

除此之外,hp 也是可以自定义的,这两者决定了箭头的形状以及大小范围。

理解了计算过程后,我们就可以模仿(照抄)剪映写出 drawArrow 的代码了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
const drawArrow = () => {
const ctx = canvasRef.value!.getContext("2d")!;
const { from, to } = currentAnnotationItem.value!;
const d = Math.sqrt(Math.pow(from.y - to.y, 2) + Math.pow(from.x - to.x, 2));
const h = Math.min(d * 0.2, 35);
const v = Math.PI / 4;
const y = Math.atan(Math.abs(to.y - from.y) / Math.abs(to.x - from.x));
const p = 0.7 * h;
const m = to.x > from.x ? 1 : -1;
const g = to.y > from.y ? 1 : -1;
const p1 = {
x: to.x - h * Math.cos(v - y) * m,
y: to.y + h * Math.sin(v - y) * g,
};
const p2 = {
x: to.x - h * Math.cos(v + y) * m,
y: to.y - h * Math.sin(v + y) * g,
};
const p3 = {
x: to.x - p * Math.cos(v - y - v / 2) * m,
y: to.y + p * Math.sin(v - y - v / 2) * g,
};
const p4 = {
x: to.x - p * Math.cos(v + y - v / 2) * m,
y: to.y - p * Math.sin(v + y - v / 2) * g,
};
ctx.beginPath();
ctx.fillStyle = "#ff0000";
ctx.moveTo(from.x, from.y);
ctx.lineTo(p3.x, p3.y);
ctx.lineTo(p1.x, p1.y);
ctx.lineTo(to.x, to.y);
ctx.lineTo(p2.x, p2.y);
ctx.lineTo(p4.x, p4.y);
ctx.fill();
ctx.closePath();
}

然后我们可以看下效果图,如下:

相关的代码已经上传到我的仓库了,可以自行拉下来跑跑试试看。

当然,如果你想把代码放到业务中,可能还需要改一下,首先就是不能使用绝对值坐标来存,而应该使用百分比,因为每个人的屏幕分辨率可能不一样,这也是目前剪映的实现方法。

另外,也可以多自定义变量,比如颜色,方框的粗细等。

当然,标注的画布应该保持一个固定的长宽比,不然缩小屏幕可能会出现错位的情况。

后记

三角函数不用真的都快忘光了,虽然从 0 到 1 我不是很行,但是从 0.99 到 1 我还是可以的😂。

目前也已经把剪映这个页面的功能都搬到了我们公司的项目上。

不过我挺讨厌抄的…

嘛,不过工作嘛,完成工作而已,不要想太多。