官网demo示例:
Image Filters
这篇讲的是如何给地图添加滤镜。
一看代码,,好家伙,信息量满满,全都看不懂。。。
咱只能一段一段扒。。。
首先添加一个底图到地图上,这个好理解。
const imagery = new TileLayer({
source: new OGCMapTile({
url: "https://maps.gnosis.earth/ogcapi/collections/NaturalEarth:raster:HYP_HR_SR_OB_DR/map/tiles/WebMercatorQuad",
crossOrigin: "",
}),
});
const map = new Map({
layers: [imagery],
target: "map",
view: new View({
center: fromLonLat([-120, 50]),
zoom: 6,
}),
});
新建一个对象定义了一组卷积核。
const 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], //增强图像的边缘和细节,但比 sharpen 更强烈。
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], //边缘检测滤波器
};
等等,啥叫卷积核?
卷积操作是一种通过一个卷积核矩阵(kernel)来滤波图像的技术,它可以实现各种图像效果,比如锐化、模糊、阴影、浮雕和边缘检测等。
啥叫卷积核矩阵?
卷积核是一个 3x3 的矩阵,每个元素代表像素在滤波时的权重。卷积操作通过将这个卷积核在图像上滑动,将核矩阵与图像中的每个 3x3 区域逐个相乘,然后求和,生成新的像素值。
例如,对于 sharpen
卷积核:
0 -1 0
-1 5 -1
0 -1 0
这个卷积核在图像上滑动时,会增强中心像素值(乘以 5)并减弱周围像素值(乘以 -1)。通过这种方式,不同的卷积核可以实现各种图像处理效果,如锐化、模糊、浮雕等。
卷积核进行归一化处理函数:
function normalize(kernel) {
// 获取卷积核的长度
const len = kernel.length;
// 创建一个与卷积核相同长度的新数组
const normal = new Array(len);
let i,
sum = 0;
// 计算卷积核中所有元素的总和
for (i = 0; i < len; ++i) {
sum += kernel[i];
}
// 如果总和小于等于0,设置sum为1并标记为未归一化
if (sum <= 0) {
normal.normalized = false;
sum = 1;
} else {
// 如果总和大于0,标记为已归一化
normal.normalized = true;
}
// 将卷积核中的每个元素除以总和,得到归一化后的值
for (i = 0; i < len; ++i) {
normal[i] = kernel[i] / sum;
}
// 返回归一化后的卷积核
return normal;
}
将卷积核中的每个元素除以总和 sum
,以确保所有元素的总和为1。这样可以保证在卷积操作过程中,图像的整体亮度不会改变 。
看到这,你是不是也跟我一样还有点懵,没事,咱们记住这句话就行:
卷积核是一个 3x3 的矩阵,每个元素代表像素在滤波时的权重。卷积操作通过将这个卷积核在图像上滑动,将核矩阵与图像中的每个 3x3 区域逐个相乘,然后求和,生成新的像素值。
也就是说,我们得获取图像中的像素数据,然后通过卷积核一类的计算操作,将图像的存储数据进行修改,生成一个新图像,最终实现滤镜效果。
那么问题来了,图像的数据是怎么存储的呢?
function convolve(context, kernel) {
const canvas = context.canvas;
const width = canvas.width;
const height = canvas.height;
const inputData = context.getImageData(0, 0, width, height).data;
// 创建一个新的 ImageData 对象用于输出图像数据
const output = context.createImageData(width, height);
const outputData = output.data;
}
使用 context.getImageData(0, 0, width, height).data来获取图像上的数据,打印一下,看到以下数组:
对于一个图像来说,inputData
中的数据是按像素顺序存储的,每个像素占用 4 个连续的数组元素,分别表示该像素的红、绿、蓝和透明度(Alpha)值。具体结构如下:
[ R, G, B, A, R, G, B, A, R, G, B, A, ... ]
假设我们有一个 2x2 像素的图像,其像素颜色如下:
- (0, 0): 红色 (255, 0, 0, 255)
- (1, 0): 绿色 (0, 255, 0, 255)
- (0, 1): 蓝色 (0, 0, 255, 255)
- (1, 1): 白色 (255, 255, 255, 255)
inputData
将会是:
[ 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255 ]
每四个一组,刚好存储了四个像素点的值。
这里有个小细节,我们在css中写颜色时透明度的取值是0-100,但实际上,计算机存储的时候范围是0-255,所以这里的透明度的取值是0-255。
知道了图像结构,我们可以在数组中获取单个像素的值。
假设我们有一个宽度为 width
的图像,要访问坐标 (x, y)
处的像素,可以通过以下方式计算索引:
const index = (y * width + x) * 4;
const red = inputData[index];
const green = inputData[index + 1];
const blue = inputData[index + 2];
const alpha = inputData[index + 3];
假设图像宽度为 2,要访问坐标 (1, 0) 处的像素(绿色)的颜色值:
const width = 2;
const x = 1;
const y = 0;
const index = (y * width + x) * 4;
const red = inputData[index]; // 0
const green = inputData[index + 1]; // 255
const blue = inputData[index + 2]; // 0
const alpha = inputData[index + 3]; // 255
了解了图像基本的存储规律,接下来我们来看具体计算函数 convolve
function convolve(context, kernel) {
const canvas = context.canvas;
const width = canvas.width;
const height = canvas.height;
// 假设 kernel 是一个归一化的卷积核矩阵,其大小为 size x size
const size = Math.sqrt(kernel.length);
const half = Math.floor(size / 2);
// 获取输入图像数据
const inputData = context.getImageData(0, 0, width, height).data;
// 创建一个新的 ImageData 对象用于输出图像数据
const output = context.createImageData(width, height);
const outputData = output.data;
// 遍历每个像素
for (let pixelY = 0; pixelY < height; ++pixelY) {
const pixelsAbove = pixelY * width;
for (let pixelX = 0; pixelX < width; ++pixelX) {
let r = 0,
g = 0,
b = 0,
a = 0;
// 遍历卷积核
for (let kernelY = 0; kernelY < size; ++kernelY) {
for (let kernelX = 0; kernelX < size; ++kernelX) {
let weight = kernel[kernelY * size + kernelX];
const neighborY = Math.min(
height - 1,
Math.max(0, pixelY + kernelY - half)
);
const neighborX = Math.min(
width - 1,
Math.max(0, pixelX + kernelX - half)
);
const inputIndex = (neighborY * width + neighborX) * 4;
// 累加加权后的像素值
r += inputData[inputIndex] * weight;
g += inputData[inputIndex + 1] * weight;
b += inputData[inputIndex + 2] * weight;
a += inputData[inputIndex + 3] * weight;
}
}
const outputIndex = (pixelsAbove + pixelX) * 4;
outputData[outputIndex] = r;
outputData[outputIndex + 1] = g;
outputData[outputIndex + 2] = b;
outputData[outputIndex + 3] = kernel.normalized ? a : 255; // 如果卷积核是归一化的,则使用计算后的 alpha,否则设为 255
}
}
// 将输出图像数据放回到画布上下文中
context.putImageData(output, 0, 0);
}
代码很多,但主要是两个循环,一个是循环所有像素点,将每个像素点进行更改。一个是循环卷积核,拿到权重生成累加权重之后的rgb值
其中 let weight = kernel[kernelY * size + kernelX]; 就是获取卷积核的权重值
假设我们有一个 3x3 的卷积核,并希望获取其中元素的位置:
const kernel = [
0, -1, 0,
-1, 5, -1,
0, -1, 0
];
const size = 3; // 卷积核的边长
// 假设我们要获取位置 (1, 1) 的权重值
const kernelX = 1;
const kernelY = 1;
const index = kernelY * size + kernelX; // 计算一维索引
const weight = kernel[index]; // 获取权重值
console.log(weight); // 输出:5
当进行卷积操作时,卷积核需要对每个像素及其周围的像素进行处理。在图像边缘处,卷积核会超出图像的边界,因此需要处理这些边界情况。neighborY
计算了在 pixelY
位置应用卷积核时相应的邻近像素的 y 坐标。
const neighborY = Math.min(
height - 1,
Math.max(0, pixelY + kernelY - half)
);
const neighborX = Math.min(
width - 1,
Math.max(0, pixelX + kernelX - half)
);
现实需求中,我们往往会碰到类似这种设计稿,地图上的地貌纹理相对突出,这时候,我们就可以加上滤镜效果来实现。
完整代码:
<template>
<div class="box">
<h1>Image Filters</h1>
<div id="map" class="map"></div>
<select id="kernel" name="kernel">
<option>none</option>
<option selected>sharpen</option>
<option value="sharpenless">sharpen less</option>
<option>blur</option>
<option>shadow</option>
<option>emboss</option>
<option value="edge">edge detect</option>
</select>
</div>
</template>
<script>
import Map from "ol/Map.js";
import View from "ol/View.js";
import XYZ from "ol/source/XYZ.js";
import { fromLonLat } from "ol/proj.js";
import { Tile as TileLayer, Vector as VectorLayer } from "ol/layer.js";
import { OGCMapTile, Vector as VectorSource } from "ol/source.js";
export default {
name: "",
components: {},
data() {
return {
map: null,
extentData: "",
message: {
name: "",
color: "",
},
};
},
computed: {},
created() {},
mounted() {
const imagery = new TileLayer({
source: new OGCMapTile({
url: "https://maps.gnosis.earth/ogcapi/collections/NaturalEarth:raster:HYP_HR_SR_OB_DR/map/tiles/WebMercatorQuad",
crossOrigin: "",
}),
});
const map = new Map({
layers: [imagery],
target: "map",
view: new View({
center: fromLonLat([-120, 50]),
zoom: 6,
}),
});
//卷积核
const 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], //增强图像的边缘和细节,但比 sharpen 更强烈。
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], //边缘检测滤波器
};
function normalize(kernel) {
// 获取卷积核的长度
const len = kernel.length;
// 创建一个与卷积核相同长度的新数组
const normal = new Array(len);
let i,
sum = 0;
// 计算卷积核中所有元素的总和
for (i = 0; i < len; ++i) {
sum += kernel[i];
}
// 如果总和小于等于0,设置sum为1并标记为未归一化
if (sum <= 0) {
normal.normalized = false;
sum = 1;
} else {
// 如果总和大于0,标记为已归一化
normal.normalized = true;
}
// 将卷积核中的每个元素除以总和,得到归一化后的值
for (i = 0; i < len; ++i) {
normal[i] = kernel[i] / sum;
}
// 返回归一化后的卷积核
return normal;
}
const select = document.getElementById("kernel");
let selectedKernel = normalize(kernels[select.value]);
select.onchange = function () {
selectedKernel = normalize(kernels[select.value]);
console.log("selectedKernel", selectedKernel);
map.render();
};
imagery.on("postrender", function (event) {
convolve(event.context, selectedKernel);
});
function convolve(context, kernel) {
const canvas = context.canvas;
const width = canvas.width;
const height = canvas.height;
// 假设 kernel 是一个归一化的卷积核矩阵,其大小为 size x size
const size = Math.sqrt(kernel.length);
const half = Math.floor(size / 2);
// 获取输入图像数据
const inputData = context.getImageData(0, 0, width, height).data;
// 创建一个新的 ImageData 对象用于输出图像数据
const output = context.createImageData(width, height);
const outputData = output.data;
// 遍历每个像素
for (let pixelY = 0; pixelY < height; ++pixelY) {
const pixelsAbove = pixelY * width;
for (let pixelX = 0; pixelX < width; ++pixelX) {
let r = 0,
g = 0,
b = 0,
a = 0;
// 遍历卷积核
for (let kernelY = 0; kernelY < size; ++kernelY) {
for (let kernelX = 0; kernelX < size; ++kernelX) {
let weight = kernel[kernelY * size + kernelX];
const neighborY = Math.min(
height - 1,
Math.max(0, pixelY + kernelY - half)
);
const neighborX = Math.min(
width - 1,
Math.max(0, pixelX + kernelX - half)
);
const inputIndex = (neighborY * width + neighborX) * 4;
// 累加加权后的像素值
r += inputData[inputIndex] * weight;
g += inputData[inputIndex + 1] * weight;
b += inputData[inputIndex + 2] * weight;
a += inputData[inputIndex + 3] * weight;
}
}
const outputIndex = (pixelsAbove + pixelX) * 4;
outputData[outputIndex] = r;
outputData[outputIndex + 1] = g;
outputData[outputIndex + 2] = b;
outputData[outputIndex + 3] = kernel.normalized ? a : 255; // 如果卷积核是归一化的,则使用计算后的 alpha,否则设为 255
}
}
// 将输出图像数据放回到画布上下文中
context.putImageData(output, 0, 0);
}
},
methods: {},
};
</script>
<style lang="scss" scoped>
#map {
width: 100%;
height: 500px;
}
.box {
height: 100%;
}
</style>