0%

OpenLayers6实例分析:Image Filters(滤镜效果)

分析 Image Filters(滤镜效果) 这个 demo,官网介绍是:

Layer rendering can be manipulated in prerender and postrender event listeners. These listeners get an event with a reference to the Canvas rendering context. In this example, the postrender listener applies a filter to the image data.
prerenderpostrender可以控制图层渲染。这两个监听器能够获得带有Canvas上下文引用的事件。在这个例子中,posetrender 事件监听器对图像数据应用了一个卷积核(滤波器)

滤镜效果

定义基本结构

先展示地图基本结构,这里的地图使用的是maptiler的服务,注册一个开发者账号获得 key。地图源 source 使用的是ol.source.XYZ,通过拼接字符串访问地图服务,可参考以前的文章OpenLayers6 瓦片地图加载

在 html 部分,还添加了 idkernelselect 标签,用于选择不同的滤镜效果。

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
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/openlayers/openlayers.github.io@master/en/v6.2.1/css/ol.css"
type="text/css"
/>
<script src="https://cdn.jsdelivr.net/gh/openlayers/openlayers.github.io@master/en/v6.2.1/build/ol.js"></script>
<title>Image Filters(滤镜效果)</title>
<style>
html,
body,
.map {
height: 100%;
width: 100%;
}
</style>
</head>

<body>
<select id="kernel" name="kernel">
<option value="none"></option>
<option value="sharpen" selected>锐化</option>
<option value="sharpenless">锐化(轻)</option>
<option value="blur">模糊</option>
<option value="shadow">阴影</option>
<option value="emboss">浮雕</option>
<option value="edge">边界识别</option>
</select>
<div id="map" class="map"></div>
<script type="text/javascript">
// 需要在https://www.maptiler.com/cloud/上注册个账号拿到key
let key = "Get your own API key at https://www.maptiler.com/cloud/";
// 所有权
let attributions =
'<a href="https://www.maptiler.com/copyright/" target="_blank">&copy; MapTiler</a> ' +
'<a href="https://www.openstreetmap.org/copyright" target="_blank">&copy; OpenStreetMap contributors</a>';
// 地图底图层
let imagery = new ol.layer.Tile({
// 使用ol.source.XYZ拼凑一个地图字符串
source: new ol.source.XYZ({
attributions: attributions,
url:
"https://api.maptiler.com/tiles/satellite/{z}/{x}/{y}.jpg?key=" +
key,
maxZoom: 20,
crossOrigin: ""
})
});
// 构建一个地图
let map = new ol.Map({
layers: [imagery],
target: "map",
view: new ol.View({
center: ol.proj.fromLonLat([-120, 50]),
zoom: 6
})
});
</script>
</body>
</html>

不同滤镜效果的卷积核

关于图像处理中的卷积核可以参考上篇文章图像处理中的卷积核 kernel

在本文的示例卷积核中就是 kernels 数组中这些 3×33 阶矩阵,利用这个卷积核和图像像素之间进行卷积运算,就能做出模糊、锐化、凹凸、边缘检测等效果。

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
// 图像处理的卷积核
let kernels = {
none: [
0, 0, 0,
0, 1, 0,
0, 0, 0
],
sharpen: [
0, -1, 0,
-1, 5, -1,
0, -1, 0
],
sharpenless: [
0, -1, 0,
-1, 10, -1,
0, -1, 0
],
blur: [
1, 1, 1,
1, 1, 1,
1, 1, 1
],
shadow: [
1, 2, 1,
0, 1, 0,
-1, -2, -1
],
emboss: [
-2, 1, 0,
-1, 1, 1,
0, 1, 2
],
edge: [
0, 1, 0,
1, -4, 1,
0, 1, 0
]
};
/**
* @description: 矩阵标准化处理,让所有的元素加起来为1
* @param {type}
* @return:返回标准化后的矩阵
*/
const normalize = kernel => {
let len = kernel.length;
let normal = new Array(len);
let i,
sum = 0;
for (i = 0; i < len; ++i) {
sum += kernel[i];
}
if (sum <= 0) {
// 矩阵各位置相加小于等于零时,设定sum=1,normalized=false
normal.normalized = false;
sum = 1;
} else {
normal.normalized = true;
}
for (i = 0; i < len; ++i) {
// 每一位除以sum来标准化
normal[i] = kernel[i] / sum;
}
return normal;
};
// 一开始就从select标签中拿到kernel并标准化
let select = document.getElementById("kernel");
let selectedKernel = normalize(kernels[select.value]);

// select标签值改变后重新标准化kernel并渲染地图
select.onchange = () => {
selectedKernel = normalize(kernels[select.value]);
map.render();
};

select 标签添加一个 onchange 事件,当改变选项时就把对应的卷积核标准化后赋给 selectedKernel,然后对地图进行redner渲染,目的是为了去触发 postrender 事件。

滤镜效果执行

imageData

经过 mentor 点醒,又查阅了MDN上的相关资料,才真正理解了 Canvas 中的imageData

imageData其实是一个 Canvas 中一个隐含像素数据的区域,有三个属性data(数据)、weight(宽度)、height(高度),用到了三个相关方法getImageData(获取)、createImageData(创建)、putImageData(写入)。

需要理解的最关键的地方就是data这个属性,它是一个 JavaScript 的很不常用的内置类型Uint8ClampedArray8位无符号整型固定数组),这个数组中每一项的处理规则为:

  • 把内容转化为Number类型
  • 如果是NaN,返回+0
  • 如果是负数,返回+0
  • 如果大于等于255,返回255
  • 如果是小数,就执行银行家舍入

简单说,就是如果你指定一个在 [0,255] 区间外的值,它将被替换为0或255;如果你指定一个非整数,那么它将被设置为最接近它的整数Uint8ClampedArray类型其余的属性和方法都和普通的Array类型一致,所以非常适用于存放 8 位二进制的颜色数据。

四层循环

imagery图层添加 postrender 事件,当改变滤镜选择的 select 标签导致地图渲染后,就会获得 Canvas 上下文并执行 convolve 函数。

convolve 函数接收两个参数,第一个参数 context 是 Canvas 上下文环境,第二个参数 kernel 是需要执行的卷积核。先拿到 Canvas 的宽度和高度,再使用 getImageData 方法获取像素数据 inputData,并用 createImageData 方法新建一个 outputData

循环像素数据 inputData 中的每一个像素,就是 Canvas 中的 imageData 像素数据就是上述width×height×4Uint8ClampedArray类型的数组,每四位代表一个像素的 r(red)g(green)b(blue)a(alpha 透明度)四个通道,所以在计算加权结果index 用的是(neighborY × width + neighborX) × 4

在每个卷积核的循环中,将带有权重rgba 分别累加,就计算出了该像素的加权后的结果,赋予给新建的 outputData 相同位置处。最后使用 putImageData 方法将 outputData 覆盖给 Canvas 上下文环境 context

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
// 图层渲染完成后就触发滤镜函数
imagery.on("postrender", event => {
convolve(event.context, selectedKernel);
});

/**
* @description: 给canvas应用一个卷积核,任意大小的卷积核都能运行,但是如果大小超过了3 x 3后性能会下降
* @param {CanvasRenderingContext2D} context canvas2d上下文
* @param {Array<number>} kernel 卷积内核
* @return:
*/
const convolve = (context, kernel) => {
// 拿到canvas上下文环境和宽高
let canvas = context.canvas;
let width = canvas.width;
let height = canvas.height;

// 核矩阵的宽度
let size = Math.sqrt(kernel.length);
let half = Math.floor(size / 2);
// 拿到canvas全部范围imageData中的像素数据
let inputData = context.getImageData(0, 0, width, height).data;
// 新建一个imageData
let output = context.createImageData(width, height);
let outputData = output.data;
// 循环列
for (let pixelY = 0; pixelY < height; ++pixelY) {
// pixelsAbove是已经运行了整行像素的像素数目
let pixelsAbove = pixelY * width;
// 循环行;
for (let pixelX = 0; pixelX < width; ++pixelX) {
// rgba是red、green、blue、alpha
let r = 0,
g = 0,
b = 0,
a = 0;
// 对于每一个像素位置上,再进行卷积核矩阵循环
for (let kernelY = 0; kernelY < size; ++kernelY) {
for (let kernelX = 0; kernelX < size; ++kernelX) {
// 由于js没有原生二维矩阵,所以用下面的转换来代替,
// weight是核矩阵该位置的权重
let weight = kernel[kernelY * size + kernelX];
// 求出该像素位置的相邻位置(与卷积核矩阵对应,并防止越界)
let neighborY = Math.min(
height - 1,
Math.max(0, pixelY + kernelY - half)
);
let neighborX = Math.min(
width - 1,
Math.max(0, pixelX + kernelX - half)
);
// 经mentor点醒,才看懂在imagedata中,数据是一个数组排列
// 每四个位置rgba分别代表一个像素
let inputIndex = (neighborY * width + neighborX) * 4;
// 该neighbor位置像素的rgba每一个都要乘该neightbor位置的weight
// 在size*size中的每一个相邻像素[neighborX,neighborY],rgba都乘该位置的权重weight
// 进行了size*size次迭代,累加起来,求出了该像素位置[pixelX,pixelY]最终的rgba
r += inputData[inputIndex] * weight;
g += inputData[inputIndex + 1] * weight;
b += inputData[inputIndex + 2] * weight;
a += inputData[inputIndex + 3] * weight;
}
}
// 把该像素的位置的累加后的rgba赋值给新建的output这个imageData的对应像素位置
let outputIndex = (pixelsAbove + pixelX) * 4;
outputData[outputIndex] = r;
outputData[outputIndex + 1] = g;
outputData[outputIndex + 2] = b;
// 如果矩阵未标准化,就把a设置为255
outputData[outputIndex + 3] = kernel.normalized ? a : 255;
}
}
// 最后把output写进上下文环境,完成替换
context.putImageData(output, 0, 0);
};

全部代码

全部代码如下:

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
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/openlayers/openlayers.github.io@master/en/v6.2.1/css/ol.css"
type="text/css"
/>
<script src="https://cdn.jsdelivr.net/gh/openlayers/openlayers.github.io@master/en/v6.2.1/build/ol.js"></script>
<title>Image Filters(滤镜效果)</title>
<style>
html,
body,
.map {
height: 100%;
width: 100%;
}
</style>
</head>

<body>
<select id="kernel" name="kernel">
<option value="none"></option>
<option value="sharpen" selected>锐化</option>
<option value="sharpenless">锐化(轻)</option>
<option value="blur">模糊</option>
<option value="shadow">阴影</option>
<option value="emboss">浮雕</option>
<option value="edge">边界识别</option>
</select>
<div id="map" class="map"></div>
<script type="text/javascript">
// 需要在https://www.maptiler.com/cloud/上注册个账号拿到key
let key = "Get your own API key at https://www.maptiler.com/cloud/";
// 所有权
let attributions =
'<a href="https://www.maptiler.com/copyright/" target="_blank">&copy; MapTiler</a> ' +
'<a href="https://www.openstreetmap.org/copyright" target="_blank">&copy; OpenStreetMap contributors</a>';
// 地图底图层
let imagery = new ol.layer.Tile({
// 使用ol.source.XYZ拼凑一个地图字符串
source: new ol.source.XYZ({
attributions: attributions,
url:
"https://api.maptiler.com/tiles/satellite/{z}/{x}/{y}.jpg?key=" +
key,
maxZoom: 20,
crossOrigin: ""
})
});
// 构建一个地图
let map = new ol.Map({
layers: [imagery],
target: "map",
view: new ol.View({
center: ol.proj.fromLonLat([-120, 50]),
zoom: 6
})
});
// 图像处理的卷积核
let kernels = {
none: [
0, 0, 0,
0, 1, 0,
0, 0, 0
],
sharpen: [
0, -1, 0,
-1, 5, -1,
0, -1, 0
],
sharpenless: [
0, -1, 0,
-1, 10, -1,
0, -1, 0
],
blur: [
1, 1, 1,
1, 1, 1,
1, 1, 1
],
shadow: [
1, 2, 1,
0, 1, 0,
-1, -2, -1
],
emboss: [
-2, 1, 0,
-1, 1, 1,
0, 1, 2
],
edge: [
0, 1, 0,
1, -4, 1,
0, 1, 0
]
};
/**
* @description: 卷积矩阵标准化处理
* @param {type}
* @return:
*/
const normalize = kernel => {
let len = kernel.length;
let normal = new Array(len);
let i,
sum = 0;
for (i = 0; i < len; ++i) {
sum += kernel[i];
}
if (sum <= 0) {
// 矩阵各位置相加小于等于零时,设定sum=1,normalized=false
normal.normalized = false;
sum = 1;
} else {
normal.normalized = true;
}
for (i = 0; i < len; ++i) {
// 每一位除以sum来标准化
normal[i] = kernel[i] / sum;
}
console.log(normal);
return normal;
};
// 一开始就从select标签中拿到kernel并标准化
let select = document.getElementById("kernel");
let selectedKernel = normalize(kernels[select.value]);

// select标签值改变后重新标准化kernel并渲染地图
select.onchange = () => {
selectedKernel = normalize(kernels[select.value]);
map.render();
};

// 图层渲染完成后就触发滤镜函数
imagery.on("postrender", event => {
convolve(event.context, selectedKernel);
});

/**
* @description: 给canvas应用一个卷积核,任意大小的卷积核都能运行,但是如果大小超过了3 x 3后性能会下降
* @param {CanvasRenderingContext2D} context canvas2d上下文
* @param {Array<number>} kernel 卷积内核
* @return:
*/
const convolve = (context, kernel) => {
// 拿到canvas上下文环境和宽高
let canvas = context.canvas;
let width = canvas.width;
let height = canvas.height;

// 核矩阵的宽度
let size = Math.sqrt(kernel.length);
let half = Math.floor(size / 2);
// 拿到canvas全部范围imageData中的像素数据
let inputData = context.getImageData(0, 0, width, height).data;
// 新建一个imageData
let output = context.createImageData(width, height);
let outputData = output.data;
// 循环列
for (let pixelY = 0; pixelY < height; ++pixelY) {
// pixelsAbove是已经运行了整行像素的像素数目
let pixelsAbove = pixelY * width;
// 循环行;
for (let pixelX = 0; pixelX < width; ++pixelX) {
// rgba是red、green、blue、alpha
let r = 0,
g = 0,
b = 0,
a = 0;
// 对于每一个像素位置上,再进行卷积核矩阵循环
for (let kernelY = 0; kernelY < size; ++kernelY) {
for (let kernelX = 0; kernelX < size; ++kernelX) {
// 由于js没有原生二维矩阵,所以用下面的转换来代替,
// weight是核矩阵该位置的权重
let weight = kernel[kernelY * size + kernelX];
// 求出该像素位置的相邻位置(与核矩阵对应,并防止越界)
let neighborY = Math.min(
height - 1,
Math.max(0, pixelY + kernelY - half)
);
let neighborX = Math.min(
width - 1,
Math.max(0, pixelX + kernelX - half)
);
// 经mentor点醒,才看懂在imagedata中,数据是一个数组排列
// 每四个位置rgba分别代表一个像素
let inputIndex = (neighborY * width + neighborX) * 4;
// 该neighbor位置像素的rgba每一个都要乘该neightbor位置的weight
// 在size*size中的每一个相邻像素[neighborX,neighborY],rgba都乘该位置的权重weight
// 进行了size*size次迭代,累加起来,求出了该像素位置[pixelX,pixelY]最终的rgba
r += inputData[inputIndex] * weight;
g += inputData[inputIndex + 1] * weight;
b += inputData[inputIndex + 2] * weight;
a += inputData[inputIndex + 3] * weight;
}
}
// 把该像素的位置的累加后的rgba赋值给新建的output这个imageData的对应像素位置
let outputIndex = (pixelsAbove + pixelX) * 4;
outputData[outputIndex] = r;
outputData[outputIndex + 1] = g;
outputData[outputIndex + 2] = b;
// 如果矩阵未标准化,就把a设置为255
outputData[outputIndex + 3] = kernel.normalized ? a : 255;
}
}
// 最后把output写进上下文环境,完成替换
context.putImageData(output, 0, 0);
};
</script>
</body>
</html>
👆 全文结束,棒槌时间到 👇