前端实现滤镜有几种方式,比如css的filter、webgl、svg等等, 其中css和svg可以实现低维度滤镜,优点是简单,方便, 无性能问题. webgl可以实现多维度滤镜, 其中选择可以自己纯手工的去撸各类滤镜算法,也可以使用现成的工具库再手动拼凑, 但是对于不懂webgl的同学来讲,着实有点麻烦. 今天分享一个基于canvas实现的影视级滤镜的方法.
lut3d
想要讲清楚滤镜是怎么回事,并不是一件容易的事情. 里面涉及到了一些概念性的东西. 这里也不得不来简单介绍一部分内容.
首先我们先来了解一下什么是lut3d.
LUT是Look Up Table的缩写,意为“查找表”。那么这个表是什么呢? 那我们又不得不介绍一下颜色的表示方式.
颜色的表示方式
一般在前端使用的颜色分为两种形式, 分别是rgba和16进制两种表现形式.
在图像像素级处理时,都会使用rgba的表现形式. 原因是通过canvas拿到的关于图像的数据, 就是rgba四通道的值的数组数据.
我们知道,世界上任意颜色,都可以通过三原色混合来得到,所以我们就可以通过数据描述三原色的强度, 来表示世界中任意一种颜色. rgba其实是描述三原色的色值,在加上一个alpha通道(透明)来表示的. 其中r表示三原色中的红色(red)、g表示三原色中的绿色(green)、b表示三原色中的蓝色(blue)、a表示alpha通道也就是透明度. 他们的取值范围是[0,255]. 我们表示一个颜色,比如说三原色的红色, 那么可以这样表示
[255, 0, 0, 255];
表示red的色值个一个不为零的数字, 其他gb两个通道的值设置为0, alpha设置为255表示不透明. 这样你将得到一个红色.
按照rgb的取值范围, 如果我们rgb三通道都取整数, 那么我们很容易算出,在这个范围内, 颜色的总数量. 一共有256 * 256 * 256种颜色表示方式. 我们知道canvas获得的图像数据,满足[0,255]的取值范围, 那么也就是说, 我们获取任意图片的任意一个点的颜色, 一定包含在这么多种颜色之中.
知道这个有什么用呢? 下面就进入到滤镜的概念范畴
滤镜的基本实现
滤镜的实现有多种多样, 但不论是什么实现方式, 其根源原理都是根据图像的源像素替换为另一个满足某种规则的像素, 这种规则可以是某种算法, 也可以是我们今天要讨论的表, 算法有很多很多, 常见的高通/低通滤波, 矩阵卷积运算等等. 我们在这里不详细讨论.
查询表
那么我们今天讨论的lut3d的这个t(table 表)到底是什么呢, 其实就是256 * 256 * 256种颜色,每种颜色所对应的滤镜颜色的映射表. 当有一个滤镜, 我们知道任意一个颜色都能通过一个映射关系,找到该颜色对应的另外一个颜色, 并将这个颜色替换到原来颜色的位置. 那么我就实现了这个滤镜.
人们为了方便理解, 将256 * 256 * 256种颜色转换成了一个三维的颜色空间, 我从网上找了两张图来表示一下 这是一个三维坐标系, 横坐标轴表示蓝色的色值, 竖坐标轴表示绿色的色值, 纵坐标轴表示红色的色值. 取值范围分别为[0,255], 在这个三维坐标系中, 任意一种颜色,都可以在其中找到一点来表示. 如果我们把各个颜色对应滤镜的颜色放置到坐标系对应的位置上, 那么我们就可以通过源色值rgb查询到对应的滤镜色值了.
为了方便后续的计算, 我们这里可以将这个三维坐标系, 在做一步转换. 把它转换为二维坐标系描述.请看下图 我们以蓝色(blue)为基准, 去三维坐标系中去找对应的一个面, 我们知道blue有256种取值, 每种取值, 都对应一个由rg两种色值组成的平面, 把得到的平面我们在一个二维坐标系中以任意方式平铺开, 那么我们就能得到一个二维的颜色描述图.
但是在实际的应用中, 如果我们严格按照[0,255]的这个取值方式去创建查询表, 那这个查询表的数据量是非常庞大的, 在我们去对一张图上的每一个像素进行查询时, 这个查询效率将会变得非常低.为了减少查询表的体积, 增加查询效率. 一般会对[0,255]的取值进行采样, 得到一份采样之后的数据. 常见的采样一般是64 * 64 * 64或者33 * 33 * 33. 上图就是通过采样后得到的64 *64 * 64的颜色平面图. 并以8 * 8 的方式进行排列, 表示蓝色(blue)的64种取值, 每个方块表示每种blue取值下的对应的rg色值的平面, 每个方块建立坐标系, 横坐标为g的色值, 纵坐标为r的色值. 同样分别具有64种取值.
经过采样之后的颜色数据, 会有一部分信息丢失. 我们需要通过一些手段, 近似的计算出丢失的信息.
将滤镜色值放在设计师通过设计软件产出的一份后缀为.cube的文件, 这个文件描述了在颜色的取值范围之内,每一种颜色所对应的滤镜颜色的色值,它是一个映射表,按照我们之前讨论的规律将这些色值进行排列。它就是我们所说的查询表(table)。
cube文件 LUT_3D_SIZE 表示采样数,该例中采样数为33 * 33 * 33 下面的值为rgb色值,每一行表示一种颜色,从上下到的顺序记录。
结合以上讲解的映射关系,我们再举个例子。 比如说,一张图片上任取一点像素,该像素的色值为(233,122,21),我们想要找到该颜色在表中对应的色值,首先要根据表中数值的取值范围,将获得的色值与表的色值进行统一。 我们样例中的色值取值范围为[0,1],所以我们先将(233,122,21)转换为[0,1]取值范围的值,也就是(233/255,122/255,21/255),结果是(0.913725,0.478431,0.082352)
上面我们讲了颜色的映射关系,在cube文件这张表上的所有颜色,都是按照从上到下的顺序,描述了上面二维图的每一个点。
这里我在解释一下: 如果这张二维图的采样也是按照333333的采样数绘制的(例如根据样例中的table来绘制),那么二维图中平铺的每个方块,都有33 * 33个像素点,一共有33个。table中数据的0—33*33行,就表示二维图中第一个方块从左到右,从上到下的每一个点。
当我们理清这些问题之后。下面开始根据我们刚才计算所得到的(0.913725,0.478431,0.082352)结果,去table里面查找对应的点的色值。我们的表是从[0,255]的取值范围中采样得到的,但是我们获取图片上的像素的取值范围却是[0,255],这样的话,table里面的数据有丢失,我们可能在table中找不到对应的映射关系,怎么办。
这里需要插值计算,来得到我们最终的颜色;插值算法有很多种类,精度也相差很多,我们这里不着重讨论,感兴趣的同学,可以学习差值算法相关的内容。我们这里为了方便理解,用一种简单的方式来处理这个问题。
在没有采样的情况下,我们知道每个点都对应一个映射关系,此时,任意一个色彩通道的值,都能计算出对应的索引。比如我们根据blue通道的21来计算,那么就有,21 / 255 * 255,计算得到它的索引为21,21除以255是为了将21转换成[0,1]的取值,再乘以255为了计算21在取值中的索引位置,虽然在没有采样的情况下,看起来完全不需要这样的计算。但是在我们样例中,就很有用了。
我们需要先用blue通道的颜色,确定我们需要找的色值在哪个方块,根据以上算法,于是可以计算得出,
var blue_idx = 0.082352 * (33 - 1);
可是我们得到了一个小数,这就是因为我们采样导致没有找到对应的映射关系,而是找到了一个近似的位置。这里我们可以分别上下取整,得到就近的两个方块的索引。33 -1是[0,33]取值的最大索引,与数组的length和index的关系一样
var blue_idx_min = Math.floor(blue_idx)
var blue_idx_max = Math.ceil(blue_idx)
得到了2,3两个索引,那么也就是对应table中的,第33 * 33 + 1 — 33 * 33 * 2行, 和第33 * 33 * 2 + 1 — 33 * 33 * 3行这两部分的数据,(因为33 * 33行是第一个方块的数据,那么下一个33 * 33行就是第二个方块的数据。)
同样我们还要按照顺序,分别计算green,和red通道的索引这里我们为了方便,直接取整,获得近似的索引(这里必须按照blue,green,red三通道的顺序来进行查询)
var green_idx = Math.floor(0.478431 * (33 - 1))
var red_idx = Math.floor(0.913725 * (33 - 1))
这样我们就得到了green通道的索引15,red通道的索引29,分别去刚才根据blue通道找到的两个方块中,去查询这个点,此时,对应table中的,第33 * 33 + 1 — 33 * 33 * 2行的数据表示第一个方块的数据,那么这个方块的横坐标为green通道的取值,纵坐标为red通道的取值,这样根据上面计算的两个索引,我们确定一个唯一的点。另外一个方块也是如此计算。
最终我们可以得到两个点的数据,这里假设我们得到了两个点的数据,(x, y, z)(ax,ay,az), 也就是对应table中的两行色值。到这里我们把两个点进行混合计算。
var p1 = [x,y,z];
var p2 = [a,b,c];
var b = 0.082352;
function mix(x, y, b) {
const a = b - Math.floor(b);
return Math.floor((x * (1 - a) + y * a) * 255);
}
var result_rgb = [
mix(p1[0], p2[0], b),
mix(p1[1], p2[1], b),
mix(p1[2], p2[2], b),
]
这样我们就能得到一个rgb的色值。 同样的,如果我们将一张图片上的所有点都使用以上的方式,替换一遍。这张图就是添加滤镜之后的图。一个视频中的每一帧都是一幅图,如果将视频中每一帧的所有点都按照上面的方式替换一遍,那么得到的视频就是添加了滤镜的视频。
下面我们来一段js代码的例子 首先,解析cube文件
function getTable(url) {
return axios(url, {
method: 'GET',
})
.then(res => {
const tableString = res.data;
const tempArr = tableString.split('\n');
let lut_3d_size = 0;
let start = -1;
const table = [], resTable = []
for (let i = 0; i < tempArr.length; i++) {
const str = tempArr[i];
if (str.includes('LUT_3D_SIZE')) {
lut_3d_size = +str.replace('LUT_3D_SIZE', '');
continue;
}
if (!str || /[a-z]/i.test(str)) continue;
if (start === -1) {
start = i;
}
const idx = i - start;
const pixel = str.split(' ').map(s => Number(s));
if (!table[Math.floor(idx / lut_3d_size)]) table[Math.floor(idx / lut_3d_size)] = [];
table[Math.floor(idx / lut_3d_size)].push(pixel);
}
for (let idx = 0; idx < table.length; idx++) {
const piece = table[idx];
if (!resTable[Math.floor(idx / lut_3d_size)]) resTable[Math.floor(idx / lut_3d_size)] = [];
resTable[Math.floor(idx / lut_3d_size)].push(piece);
}
return {
table: resTable,
size: lut_3d_size
}
})
.catch(err => {
console.error(err)
})
}
然后我们来实现根据点查询table的方法
function mix(x, y, b) {
const a = b - Math.floor(b);
return Math.floor((x * (1 - a) + y * a) * 255);
}
function lut3d(targetColor, table, lut3dSize) {
const [r, g, b] = targetColor || [];
const tr = r / 255;
const tg = g / 255;
const tb = b / 255;
const n = lut3dSize - 1;
const b_index = tb * n;
const r_index = Math.floor(tr * n);
const g_index = Math.floor(tg * n);
const b_floor_idx = Math.floor(b_index);
const b_ceil_idx = Math.ceil(b_index);
const b_ceil = table[b_ceil_idx];
const b_floor = table[b_floor_idx];
const g_ceil = b_ceil[g_index];
const g_floor = b_floor[g_index];
const r_ceil = g_ceil[r_index];
const r_floor = g_floor[r_index];
return [
mix(r_ceil[0], r_floor[0], tb),
mix(r_ceil[1], r_floor[1], tb),
mix(r_ceil[2], r_floor[2], tb),
]
}
最后实现个demo,看下效果
const test_cube_file = '';
const video_url = '';
getTable(test_cube_file).then((res: any) => {
const { table, size } = res;
const canvas = document.createElement("canvas");
const video = document.createElement("video");
const play_button = document.createElement("button");
play_button.innerHTML = '播放';
canvas.style.cssText = `
position:absolute;
top:50%;
left:50%;
transform:translate(-110%,-50%);
border:1px solid #333;
z-index:9999999;
`;
video.style.cssText = `
position:absolute;
top:50%;
left:50%;
transform:translate(10%,-50%);
border:1px solid #333;
z-index:9999999;
`
play_button.style.cssText = `
position:absolute;
top:50%;
left:50%;
transform:translate(-50%,-50%);
border:1px solid #333;
z-index:9999999;
`
const ctx = canvas.getContext("2d");
video.crossOrigin = 'anonymous';
video.src = video_url;
video.oncanplaythrough = () => {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
video.loop = true;
checkVideo();
}
function checkVideo() {
ctx?.drawImage(video, 0, 0, video.videoWidth, video.videoHeight);
const video_image_data = ctx?.getImageData(0, 0, canvas.width, canvas.height);
const imageData = new ImageData(video_image_data.width, video_image_data.height)
const video_pixel_data = video_image_data.data;
for (let i = 0; i < imageData.data.length; i += 4) {
const vr = video_pixel_data[i];
const vg = video_pixel_data[i + 1];
const vb = video_pixel_data[i + 2];
const va = video_pixel_data[i + 3];
const [r, g, b] = lut3d([vr, vg, vb], table, size);
imageData.data[i] = r
imageData.data[i + 1] = g
imageData.data[i + 2] = b
imageData.data[i + 3] = va;
}
ctx?.putImageData(imageData, 0, 0);
window.requestAnimationFrame(checkVideo)
}
play_button.onclick = () => {
video.play();
}
document.body.appendChild(canvas);
document.body.appendChild(video);
document.body.appendChild(play_button);
});
测试cube文件 https://cdn.aidigger.com/general_upload/Modi/ModiKuro/upload/f589bc262e784290aaa9cccf40cea355.cube
|