用Canvas在视频里画区域


需求:

从接口拿到视频流,然后用video播放视频流,切换到绘制模式的时候,可以在视频里规划区域。然后将区域的坐标反馈给后端,后端再反馈给硬件,这样就实现了摄像头监控指定的区域。

最后实现出来的页面相对较复杂,跨过了很多的坑,我这里的话就把最核心的分享出来,其他的一些坑放到文章的最后,接下来就实现一个demo级别的画区域操作。

Demo效果如下:

首先我们要思考怎么才能让多端画出来的区域比例是一样的,在公司硬件大佬(司工)的耐心解答下,终于让我茅塞顿开,非常感激。

如果想要保持多端区域一致,那么就要先保证视频流的分辨率一致,如果视频分辨率都不一样了,画的区域肯定就对不上,接下来我们约定好了,将视频画分为长25个网格,宽25个网格的一个画布,这样就可以保证画出来的区域,摄像头和网页都一样。我一直困惑的点在为了video展示效果好,会对video包裹元素进行缩放,那么画的区域是不是就对不上了?答案是不会的,因为只要画25的网格,他们最终的每个网格的比例是一样的,就应该对的上。仅仅就是视频画质看上去效果不一样的问题罢了。

这里我们就做一个500 * 500的来演示。

我们用canvas画25个网格就可以适配多端了,但是有个问题就是,不同的屏幕,为了绘制出25个网格,它大概率不是正方形的,这个无解的。

还要了解到,在canvas里它的xy是坐标系,是基于画布世界里的坐标系。

好了我们先安装以下插件

  • “react”: “^17.0.2”
  • “react-konva”: “^17.0.2-6”
  • “konva”: “^7.2.5”
  • “react-player”: “^2.12.0”

因为我这里还在用React17所以只能装对应的版本,这里canvas库我们用的是react-konva

绘制500 * 500的video和画布,2个元素一叠加就可以看到视频上有网格的效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<div className={styles['canvas-panel']}>
<ReactPlayer
ref={videoRef}
url={videoUrl} // https://bitdash-a.akamaihd.net/content/sintel/hls/playlist.m3u8
width={500}
height={500}
style={{
position:"absolute",
top: 0,
left: 0,
}}
config={{
file: {
attributes: {autoplay:"autoplay"},
forceHLS: true,
}
}}
/>
<PreviewSettingBox drawing={drawing} />
</div>
1
2
3
4
5
6
7
8
.canvas-panel {
position: relative;
width: 500px;
height: 500px;
}
video {
object-fit: fill;
}

接下来就是绘画的操作了,自己看吧

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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import Konva from "konva";
import { useRedux } from '@/hooks/useRedux';

let stage; // 定义画布
let layer; // 图层
let grids; // 网格
let mousedown = false;
let startPos: { x: number, y: number } | null; // 鼠标点击当前的坐标(坐标对标格子的下标)
const count = 25; // 总共 x,y分别都是25个格子
const itemW = 20; // 每个网格的宽度
const itemH = 20; // 每个网格的高度
let detectAreaData: any = []; // 整个检测区域
// detectAreaData是这样的一个结构
// [[true,true,false...25个],...]25个
// 就是总共从0开始,一共25个下标,每个下标又是一个数组,数组里是25个true/false

interface IProps {
drawing: boolean; // 是否开启绘制模式
}

export interface ISelectGridData {
x: number;
y: number;
}

export const PreviewSettingBox: React.FC<IProps> = (props) => {
const { drawing } = props;
const canvasContainerRef = React.useRef<HTMLDivElement>(null);
const [selectGridData, setSelectGridData] = useState<ISelectGridData[]>();

const handleRender = useCallback(() => {
const data: { x:number;y:number }[] = [];
for (let x = 0; x < count; x++) {
for (let y = 0; y < count; y++) {
if (detectAreaData?.length > 0 && detectAreaData[x][y] === true) { // 为true就是画上的
grids[x][y].attrs.stroke = "red";
grids[x][y].attrs.fill = "rgba(255,0,0,0.3)";
grids[x][y].attrs.zIndex = 399;
data.push({ x,y });
} else {
grids[x][y].attrs.stroke = "rgba(0,0,0,0.3)";
grids[x][y].attrs.fill = null;
grids[x][y].attrs.zIndex = 0;
}
}
}
setSelectGridData(data);
layer.draw();
}, [])

// 根据鼠标的坐标去判断选中还是取消区域
const selectGridRect = useCallback((x1: number, y1: number, x2: number, y2: number) => {
for (let x = 0; x < count; x++) {
for (let y = 0; y < count; y++) {
if (x >= x1 && x <= x2 && y >= y1 && y <= y2) {
detectAreaData[x][y] = true;
} else {
detectAreaData[x][y] = false;
}
}
}
handleRender();
}, [handleRender])

// 为每个网格添加鼠标的监听事件 or 移除事件
const updateDrawCanvas = useCallback(() => {
for (let x = 0; x < count; x++) {
for (let y = 0; y < count; y++) {
grids[x][y].removeEventListener("mousemove");
grids[x][y].removeEventListener("click");
grids[x][y].on("mousemove", (e) => {
if (mousedown) {
if (e.evt.which === 1) {
if (!startPos) {
startPos = { x, y };
} else {
selectGridRect(startPos.x, startPos.y, x, y);
}
}
}
});
if (detectAreaData?.length > 0 && detectAreaData[x][y] === true) {
grids[x][y].attrs.stroke = "red";
grids[x][y].attrs.fill = "rgba(255,0,0,0.3)";
grids[x][y].attrs.zIndex = 399;
} else {
grids[x][y].attrs.stroke = "rgba(0,0,0,0.3)";
grids[x][y].attrs.fill = null;
grids[x][y].attrs.zIndex = 0;
}
}
}
layer.draw();
}, [selectGridRect])

const initStage = useCallback(() => {
if (stage) {
stage.destroy();
}
stage = new Konva.Stage({
container: canvasContainerRef.current!,
width: 500,
height: 500,
dragBoundFunc: (pos) => {
return pos;
},
});
stage.on("mousedown", () => {
mousedown = true; // 只有在鼠标点击的那一刻,才开始监听画布
});
stage.on("mouseup", () => {
mousedown = false;
startPos = null;
});
stage.on("mouseleave", () => {
mousedown = false;
});
}, [])

const initGrids = useCallback(() => {
if (layer) {
layer.destroy();
}
layer = new Konva.Layer();
grids = new Array<Array<Konva.Rect>>();
for (let x = 0; x < count; x++) { // 初始化网格
grids[x] = [];
detectAreaData[x] = [];
for (let y = 0; y < count; y++) {
detectAreaData[x][y] = [];
const gridItem = new Konva.Rect({
...{
x: x * itemW,
y: y * itemH,
width: itemW,
height: itemH,
},
name: "rect",
strokeWidth: 1,
draggable: false,
stroke: "rgba(0,0,0,0.3)",
fill: "white"
});
grids[x].push(gridItem);
layer.add(gridItem);
}
}
stage.add(layer);
layer.draw();
}, [])

useEffect(() => {
if (drawing) {
initStage();
initGrids();
}
}, [drawing, initGrids, initStage])

useEffect(() => {
if (drawing && detectAreaData?.length > 0) {
updateDrawCanvas();
}
}, [drawing, updateDrawCanvas])

return useMemo(() => {
return (
<div className='canvas-wrap' id="canvas-wrap">
{
drawing && <div ref={canvasContainerRef} style={{ width: "100%", height: "100%" }}></div>
}
{
selectGridData?.length && <div>
{
selectGridData.map((item) => {
return <p>x:{item.x}, y:{item.y}</p>
})
}
</div>
}
</div>
)
}, [drawing, selectGridData])
};

遇到的坑

  • 我的需求是要在视频全屏的状态下画区域,但是因为video全屏时元素层级问题,我的网格根本覆盖不上去,所以借助了一个插件“react-full-screen”: “^1.1.1”,就可以让画布覆盖上去了。
  • 还是video层级问题,antd的message和Modal覆盖不上去,借助message.config({getContainer})getContainer={document.getElementById(“canvas-wrap”)}就可以实现。

我的微信公众号: 梨的前端小屋


文章作者: 梨啊梨
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 梨啊梨 !
  目录