🤣🤣电摇嘲讽还能这么玩?使用 Canvas 将视频转为像素风!
前言
今天闲来无事,上 b 站逛了圈,发现石头老师又出新视频了,这期讲的是将视频转为字符视频,看起来很有意思,本文来剖析它的实现原理。
前置知识
本文涉及到两个知识点,这里先跟大家过一下,有利于大家后续的学习。
CanvasRenderingContext2D.drawImage
Canvas 2D API 中的 CanvasRenderingContext2D.drawImage()
方法提供了多种在画布(Canvas)上绘制图像的方式。我们可以通过这个方法将视频帧在 canvas 上绘制成图像。
语法
drawImage(image, dx, dy, dWidth, dHeight)
参数
-
image
绘制到上下文的元素。允许任何的画布图像源(本文使用到的是 HTMLVideoElement,更多细节请看下面)。
-
sx
可选需要绘制到目标上下文中的,
image
的矩形(裁剪)选择框的左上角 X 轴坐标。可以使用 3 参数或 5 参数语法来省略这个参数。 -
sy
可选需要绘制到目标上下文中的,
image
的矩形(裁剪)选择框的左上角 Y 轴坐标。可以使用 3 参数或 5 参数语法来省略这个参数。 -
sWidth
可选需要绘制到目标上下文中的,
image
的矩形(裁剪)选择框的宽度。如果不说明,整个矩形(裁剪)从坐标的sx
和sy
开始,到image
的右下角结束。可以使用 3 参数或 5 参数语法来省略这个参数。使用负值将翻转这个图像。 -
sHeight
可选需要绘制到目标上下文中的,
image
的矩形(裁剪)选择框的高度。使用负值将翻转这个图像。
更多
更多关于 drawImage 的细节,请看:CanvasRenderingContext2D.drawImage() - Web API 接口参考 | MDN (mozilla.org)
CanvasRenderingContext2D.getImageData
CanvasRenderingContext2D.getImageData()
返回一个 ImageData
对象,用来描述 canvas 区域隐含的像素数据,这个区域通过矩形表示,起始点为 (sx, sy)
、宽为 sw
、高为 sh
。
语法
ImageData ctx.getImageData(sx, sy, sw, sh);
参数
-
sx
将要被提取的图像数据矩形区域的左上角 x 坐标。
-
sy
将要被提取的图像数据矩形区域的左上角 y 坐标。
-
sw
将要被提取的图像数据矩形区域的宽度。
-
sh
将要被提取的图像数据矩形区域的高度。
返回值
一个 ImageData
对象,包含 canvas 给定的矩形图像数据。
这里我们暂且不用管 ImageData
对象是什么,我们只需要知道,ImageData
中有一个 data
属性,它是一个 Uint8ClampedArray
,描述了一个一维数组,包含以 rgba 顺序的数据,数据使用 0
至 255
(包含)的整数表示。
这么说可能有点抽象,我们直接输出看看长啥样。
更多
更多关于 getImageData 的细节,请看:CanvasRenderingContext2D.getImageData() - Web API 接口参考 | MDN (mozilla.org)
需求分析
前置知识学习完,我们就可以开始搞事了,但是,一下子就要将视频转为字符视频可能有点唐突了,那我们先尝试将视频转为图像。
将视频帧转为图像
<body>
<video id="video" oncanplay="init()" loop width="400" src="./video/1.mp4"></video>
<canvas id="cvs"></canvas>
<script>
const ctx = cvs.getContext('2d');
const ctx2 = cvs2.getContext('2d');
cvs.height = cvs2.height = video.offsetHeight;
cvs.width = cvs2.width = video.offsetWidth;
const { width, height } = cvs;
ctx.drawImage(video, 0, 0, width, height);
</script>
</body>
我们可以通过前置知识中的 drawImage
方法,将 vedio 作为第一个参数传入,同时 x
轴位置和 y
轴位置都从 0
开始,表示从左上角开始裁切,width
和 height
我们传入 video 的实际宽高(通过 offsetWidth 和 offsetHeight 获取),表示我们需要从左上角开始,裁剪出和 video 同样大小的图像。
如此,我们就可以实现将视频第一帧绘制到 canvas 画布的功能。
相信小伙伴们到这一步都没有问题。
图像像素风
接下来我们要将 canvas 上的图像绘制为像素风。
这里我们需要准备一个 新的 canvas 来绘制像素风图像。
有的小伙伴可能比较疑惑为什么要用另一个 canvas 来绘制,这是因为我们一开始需要将视频帧绘制到一个 canvas 上,所以准备了一个 canvas,然后我们要对这个 canvas 上的图像进行像素化,如果用的是同一个canvas,会将图像覆盖掉,所以才需要准备一个新的 canvas 来绘制。
<video id="video" oncanplay="init()" loop width="400" src="./video/1.mp4"></video>
<canvas id="cvs"></canvas>
<canvas id="cvs2" onclick="video.play()"></canvas>
怎么对图像进行像素化呢?聪明的小伙伴想到了之前介绍的 getImageData
方法了。
上面提到过,ImageData
对象的 data
属性,是按 rgba 顺序的 表示所有像素点颜色信息的一维数组。因此我们 4 个一组进行处理,前三个是 rgb,我们将它们三个相加后乘上一个比例,作为灰度值赋值给 fillStyle
,第四个是透明值,大家根据喜好进行调整。
for(let i=0; i<data.length; i+=4) {
const x = parseInt(i % (width*4) / 4);
const y = parseInt(i / (width * 4));
const g = parseInt((data[i]+data[i+1]+data[i+2])/3);
ctx2.fillStyle = `rgba(${g}, ${g}, ${g}, ${data[i+3]})`;
ctx2.fillText('□', x, y);
}
可以发现如果我们处理所有的像素点,那么最后呈现出来的图像(最后一张图)很圆润(像素点很密),和原图差别不大。
我们为了模拟像素风视频,需要将像素点进行 稀释。
有的小伙伴可能不知道稀释是什么意思,我拿下面三张图片举个例子。
图1的像素点比较多,展现出来的效果会更圆润,专业点说就是没有锯齿。
图2的像素点相较于图1来说偏少,我们可以很明显的看出一块一块的方格,它是有锯齿的。
图3的像素点就更少了,方格我们甚至可以数的出来。
为了让它的像素点少一点,至少要看着少一点,我们才要进行稀释。
我们设置一个稀释比例 bl
,当 x
和 y
的值满足条件的时候,绘制一个像素点,这样我们不仅可以满足我们稀释的要求,还可以少处理很多像素点,减少绘制性能消耗。
const bl = 8;
for(let i=0; i<data.length; i+=4) {
const x = parseInt(i % (width*4) / 4);
const y = parseInt(i / (width * 4));
if(x % bl === 0 && y % bl === 0) {
const g = parseInt((data[i]+data[i+1]+data[i+2])/3);
ctx2.fillStyle = `rgba(${g}, ${g}, ${g}, ${data[i+3]})`;
ctx2.fillText('□', x, y);
}
}
视频像素风
通过前面几个步骤,我们已经可以将视频帧绘制为像素风图像了,那么怎么将视频变为像素风视频呢?
其实很容易,我们一次只能绘制一帧的视频,那如果我 对每帧视频都进行绘制(下一帧绘制前先将画布清空,然后绘制新的一帧),输出的是不是就是像素风视频了?
const playVideo = () => {
requestAnimationFrame(playVideo);
... do something
ctx2.clearRect(0, 0, width, height); // 清空画布(清空上一帧图像)
... 绘制新的一帧
}
注意这里用到了 requestAnimationFrame
这个 api(setTimeInterval
的延迟时间 delay
指的是在 delay
秒后,将回调函数加入事件队列,如果该回调在事件队列中仍在执行,则会跳过当前回调函数进队列,导致了延迟的发生),它的作用是替代 setInterval
,调用频率为 每秒 60 次,提高性能。
我们递归调用直至视频处理结束。
完整代码
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
body {
display: flex;
}
video, canvas {
border: 1px solid;
}
</style>
</head>
<body>
<video id="video" oncanplay="init()" loop width="400" src="./video/1.mp4"></video>
<canvas id="cvs" hidden></canvas>
<canvas id="cvs2" onclick="video.play()"></canvas>
<script>
const init = () => {
const ctx = cvs.getContext('2d');
const ctx2 = cvs2.getContext('2d');
cvs.height = cvs2.height = video.offsetHeight;
cvs.width = cvs2.width = video.offsetWidth;
const playVideo = () => {
requestAnimationFrame(playVideo);
const { width, height } = cvs;
ctx.drawImage(video, 0, 0, width, height);
const data = ctx.getImageData(0, 0, width, height).data;
ctx2.clearRect(0, 0, width, height);
const bl = 8;
for(let i=0; i<data.length; i+=4) {
const x = parseInt(i % (width*4) / 4);
const y = parseInt(i / (width * 4));
if(x % bl === 0 && y % bl === 0) {
const g = parseInt((data[i]+data[i+1]+data[i+2])/3);
ctx2.fillStyle = `rgba(${g}, ${g}, ${g}, ${data[i+3]})`;
ctx2.fillText('□', x, y);
}
}
}
playVideo();
}
</script>
</body>
</html>
源码地址
所有源码包括视频已上传到 github,大家可以自行观看。
juejin-demo/canvas-demo at main · catwatermelon/juejin-demo (github.com)
结束语
学习可能是枯燥的,但是如果能在玩中学到东西,那是最好不过的了。通过这个案例,我们学会了两个 canvas 方法,并能通过这两个方法实现字符视频,希望大家阅读之后都能有所收获。
转载自:https://juejin.cn/post/7151879459249848351